Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9a7db0231 | ||
|
|
3e126c0ddd | ||
|
|
438dc11548 | ||
|
|
652e0250f3 |
@@ -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<HttpResult>}
|
||||
*/
|
||||
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);
|
||||
|
||||
@@ -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,81 @@
|
||||
export const ASYNC_BASE_STATES = Object.freeze([
|
||||
"initial-loading",
|
||||
"success",
|
||||
"empty",
|
||||
"terminal-error",
|
||||
]);
|
||||
|
||||
export const ASYNC_OVERLAYS = Object.freeze([
|
||||
"refreshing",
|
||||
"stale-degraded",
|
||||
"mutation-pending",
|
||||
"mutation-conflict",
|
||||
]);
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* data?: unknown,
|
||||
* isInitialLoading?: boolean,
|
||||
* failure?: import("../../contracts/errors.js").ApiFailure,
|
||||
* isFetching?: boolean,
|
||||
* isStale?: boolean,
|
||||
* isDegraded?: boolean,
|
||||
* isMutationPending?: boolean,
|
||||
* hasMutationConflict?: boolean
|
||||
* }} AsyncSignals
|
||||
*/
|
||||
|
||||
/** @param {AsyncSignals} signals */
|
||||
export function deriveAsyncState(signals) {
|
||||
const hasData = signals.data !== undefined && signals.data !== null;
|
||||
const empty =
|
||||
hasData &&
|
||||
((Array.isArray(signals.data) && signals.data.length === 0) ||
|
||||
signals.data === "");
|
||||
|
||||
let base;
|
||||
if (signals.isInitialLoading && !hasData) {
|
||||
base = "initial-loading";
|
||||
} else if (signals.failure && !hasData) {
|
||||
base = "terminal-error";
|
||||
} else if (empty) {
|
||||
base = "empty";
|
||||
} else if (hasData) {
|
||||
base = "success";
|
||||
} else {
|
||||
base = "initial-loading";
|
||||
}
|
||||
|
||||
const overlay = Object.freeze({
|
||||
refreshing: Boolean(signals.isFetching && hasData),
|
||||
staleDegraded: Boolean(signals.isStale && signals.isDegraded && hasData),
|
||||
mutationPending: Boolean(signals.isMutationPending && hasData),
|
||||
mutationConflict: Boolean(signals.hasMutationConflict && hasData),
|
||||
});
|
||||
|
||||
const state = {
|
||||
base,
|
||||
data: base === "success" || base === "empty" ? signals.data : undefined,
|
||||
failure: base === "terminal-error" ? signals.failure : undefined,
|
||||
overlay,
|
||||
indicator: selectOverlayIndicator(overlay),
|
||||
};
|
||||
|
||||
return Object.freeze(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* refreshing: boolean,
|
||||
* staleDegraded: boolean,
|
||||
* mutationPending: boolean,
|
||||
* mutationConflict: boolean
|
||||
* }} overlay
|
||||
*/
|
||||
export function selectOverlayIndicator(overlay) {
|
||||
if (overlay.mutationConflict) return "mutation-conflict";
|
||||
if (overlay.mutationPending) return "mutation-pending";
|
||||
if (overlay.staleDegraded) return "stale-degraded";
|
||||
if (overlay.refreshing) return "refreshing";
|
||||
return null;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -9,13 +9,18 @@ const DROP_SENSITIVE = Object.freeze([
|
||||
"storageValue",
|
||||
]);
|
||||
|
||||
/**
|
||||
* @typedef {"retry" | "reauth" | "navigate" | "reload-once" |
|
||||
* "contact-support" | "none"} ErrorAction
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* kind: string,
|
||||
* defaultRetryable: boolean,
|
||||
* severity: string,
|
||||
* userMessageKey: string,
|
||||
* action: string,
|
||||
* action: ErrorAction,
|
||||
* telemetryEvent: string,
|
||||
* redaction: readonly string[]
|
||||
* }} ErrorDefinition
|
||||
@@ -25,7 +30,7 @@ const DROP_SENSITIVE = Object.freeze([
|
||||
* @param {string} kind
|
||||
* @param {boolean} defaultRetryable
|
||||
* @param {string} severity
|
||||
* @param {string} action
|
||||
* @param {ErrorAction} action
|
||||
* @param {string} [telemetryEvent]
|
||||
* @returns {Readonly<ErrorDefinition>}
|
||||
*/
|
||||
@@ -156,7 +161,7 @@ export const ERROR_REGISTRY = Object.freeze({
|
||||
* traceId?: string,
|
||||
* retryAfterMs?: number,
|
||||
* userMessageKey: string,
|
||||
* action: string,
|
||||
* action: ErrorAction,
|
||||
* causeClass?: string
|
||||
* }} ApiFailure
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/** @param {{ label?: string }} props */
|
||||
export function LoadingSurface({ label = "불러오는 중" }) {
|
||||
return (
|
||||
<section aria-busy="true" aria-label={label}>
|
||||
<div className="ui-skeleton" aria-hidden="true" />
|
||||
<span className="sr-only">{label}</span>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {{ title?: string, action?: React.ReactNode }} props */
|
||||
export function EmptySurface({ title = "표시할 항목이 없습니다.", action }) {
|
||||
return (
|
||||
<section>
|
||||
<p>{title}</p>
|
||||
{action}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* userMessageKey: string,
|
||||
* action: "retry" | "reauth" | "navigate" | "reload-once" |
|
||||
* "contact-support" | "none",
|
||||
* onAction?: () => void
|
||||
* }} props
|
||||
*/
|
||||
export function TerminalErrorSurface({ userMessageKey, action, onAction }) {
|
||||
return (
|
||||
<section role="alert" aria-labelledby="terminal-error-message">
|
||||
<p id="terminal-error-message">{userMessageKey}</p>
|
||||
{action !== "none" && (
|
||||
<button type="button" onClick={onAction}>
|
||||
{action}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* state: ReturnType<typeof import("../../application/view-models/async-state.js").deriveAsyncState>,
|
||||
* children?: React.ReactNode,
|
||||
* onAction?: () => void
|
||||
* }} props
|
||||
*/
|
||||
export function AsyncSurface({ state, children, onAction }) {
|
||||
if (state.base === "initial-loading") return <LoadingSurface />;
|
||||
if (state.base === "empty") return <EmptySurface />;
|
||||
if (state.base === "terminal-error" && state.failure) {
|
||||
return (
|
||||
<TerminalErrorSurface
|
||||
userMessageKey={state.failure.userMessageKey}
|
||||
action={state.failure.action}
|
||||
onAction={onAction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-busy={state.overlay.refreshing || state.overlay.mutationPending}>
|
||||
{state.indicator && (
|
||||
<p role="status" aria-live="polite">
|
||||
{state.indicator}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { deriveAsyncState } from "../../src/application/view-models/async-state.js";
|
||||
import { AsyncSurface } from "../../src/presentation/components/async-surface.jsx";
|
||||
import { createFailure } from "../../src/contracts/errors.js";
|
||||
|
||||
describe("async UI state matrix", () => {
|
||||
it.each([
|
||||
[{ isInitialLoading: true }, "initial-loading"],
|
||||
[{ data: [{ id: "1" }] }, "success"],
|
||||
[{ data: [] }, "empty"],
|
||||
[
|
||||
{ failure: createFailure("SERVER_FAILURE", "LIST", 0) },
|
||||
"terminal-error",
|
||||
],
|
||||
])("derives base state %#", (signals, expected) => {
|
||||
expect(deriveAsyncState(signals).base).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ data: ["value"], isFetching: true }, "refreshing"],
|
||||
[{ data: ["value"], isStale: true, isDegraded: true }, "stale-degraded"],
|
||||
[{ data: ["value"], isMutationPending: true }, "mutation-pending"],
|
||||
[{ data: ["value"], hasMutationConflict: true }, "mutation-conflict"],
|
||||
])("derives overlay state %#", (signals, indicator) => {
|
||||
expect(deriveAsyncState(signals).indicator).toBe(indicator);
|
||||
});
|
||||
|
||||
it("uses deterministic overlay priority for crossed states", () => {
|
||||
const state = deriveAsyncState({
|
||||
data: ["value"],
|
||||
isFetching: true,
|
||||
isMutationPending: true,
|
||||
hasMutationConflict: true,
|
||||
});
|
||||
expect(state.indicator).toBe("mutation-conflict");
|
||||
expect(state.overlay).toMatchObject({
|
||||
refreshing: true,
|
||||
mutationPending: true,
|
||||
mutationConflict: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps content visible while a non-blocking refresh runs", () => {
|
||||
const state = deriveAsyncState({ data: ["value"], isFetching: true });
|
||||
render(<AsyncSurface state={state}>existing content</AsyncSurface>);
|
||||
|
||||
expect(screen.getByText("existing content")).toBeVisible();
|
||||
expect(screen.getByRole("status")).toHaveTextContent("refreshing");
|
||||
});
|
||||
|
||||
it("renders only safe error vocabulary", () => {
|
||||
const failure = createFailure("SERVER_FAILURE", "LIST", 0, {
|
||||
code: "SERVER_FAILURE",
|
||||
});
|
||||
const state = deriveAsyncState({ failure });
|
||||
render(<AsyncSurface state={state} />);
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(failure.userMessageKey);
|
||||
expect(screen.getByRole("button")).toHaveTextContent("retry");
|
||||
expect(screen.getByRole("alert")).not.toHaveTextContent("stack");
|
||||
});
|
||||
|
||||
it("does not retain a terminal error after usable data is restored", () => {
|
||||
const failed = deriveAsyncState({
|
||||
failure: createFailure("SERVER_FAILURE", "LIST", 0),
|
||||
});
|
||||
const recovered = deriveAsyncState({ data: ["value"] });
|
||||
expect(failed.base).toBe("terminal-error");
|
||||
expect(recovered.base).toBe("success");
|
||||
expect(recovered.failure).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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