feat: 기능 추가 과정중
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
export type BoundedJsonResult =
|
||||
| Readonly<{ ok: true; value: unknown }>
|
||||
| Readonly<{ ok: false; code: "RESPONSE_BODY_LIMIT" | "MALFORMED_JSON" }>;
|
||||
|
||||
export async function readBoundedJson(
|
||||
response: Response,
|
||||
maxBytes: number,
|
||||
): Promise<BoundedJsonResult> {
|
||||
const declaredLength = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
||||
await response.body?.cancel();
|
||||
return { ok: false, code: "RESPONSE_BODY_LIMIT" };
|
||||
}
|
||||
if (!response.body) return { ok: false, code: "MALFORMED_JSON" };
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const next = await reader.read();
|
||||
if (next.done) break;
|
||||
total += next.value.byteLength;
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel();
|
||||
return { ok: false, code: "RESPONSE_BODY_LIMIT" };
|
||||
}
|
||||
chunks.push(next.value);
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, code: "MALFORMED_JSON" };
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
value: JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)),
|
||||
};
|
||||
} catch {
|
||||
return { ok: false, code: "MALFORMED_JSON" };
|
||||
}
|
||||
}
|
||||
@@ -1,589 +0,0 @@
|
||||
import { systemClock } from "../platform/system-clock.js";
|
||||
import { getApiOperation } from "../../contracts/api-operations.js";
|
||||
import {
|
||||
createFailure as failure,
|
||||
kindForStatus as statusKind,
|
||||
normalizeUnknownFailure,
|
||||
safeValidationIssues,
|
||||
} from "../../contracts/errors.js";
|
||||
import { mapOperationPayload } from "./resource-mapper.js";
|
||||
import { retryDelay, shouldRetry } from "./retry-policy.js";
|
||||
import {
|
||||
validateEnvelope,
|
||||
validateOperationPayload,
|
||||
validateOperationRequest,
|
||||
} from "./schema-registry.js";
|
||||
import { buildRequestTarget } from "./request-builder.js";
|
||||
import {
|
||||
attemptBucket,
|
||||
durationBucket,
|
||||
statusGroup,
|
||||
} from "../../contracts/diagnostics.js";
|
||||
|
||||
const noAuthSession =
|
||||
/** @type {import("../../application/ports/auth-session-port.js").AuthSessionPort} */ ({
|
||||
getState: () => /** @type {"unauthenticated"} */ ("unauthenticated"),
|
||||
attach: async (request) => request,
|
||||
recover: async () => /** @type {"no-session"} */ ("no-session"),
|
||||
onUnauthenticated: () => {},
|
||||
});
|
||||
|
||||
/** @typedef {import("../../contracts/errors.js").ApiFailure} HttpFailure */
|
||||
/** @typedef {import("./request-builder.js").OperationRequestInput} OperationRequestInput */
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* setTimeout(callback: () => void, milliseconds: number): unknown,
|
||||
* clearTimeout(handle: unknown): void
|
||||
* }} Scheduler
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{ ok: true, value: unknown, meta: Record<string, string> } |
|
||||
* { ok: false, error: HttpFailure }} HttpResult
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* baseUrl: string,
|
||||
* fetcher?: typeof fetch,
|
||||
* authSession?: import("../../application/ports/auth-session-port.js").AuthSessionPort,
|
||||
* clock?: import("../../application/ports/clock-port.js").ClockPort,
|
||||
* random?: () => number,
|
||||
* validatePayload?: (schemaId: string, value: unknown) =>
|
||||
* { success: true, data: unknown } | { success: false },
|
||||
* validateRequest?: (schemaId: string, value: unknown) =>
|
||||
* { success: true, data: unknown } | { success: false },
|
||||
* mapPayload?: (operationId: string, payload: unknown) => unknown,
|
||||
* idempotencyKeyFactory?: () => string,
|
||||
* timeoutMs?: number,
|
||||
* maxRetryAttempts?: number,
|
||||
* scheduler?: Scheduler,
|
||||
* getOperation?: typeof getApiOperation,
|
||||
* diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort,
|
||||
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort,
|
||||
* correlationIdFactory?: () => string
|
||||
* }} dependencies
|
||||
*/
|
||||
export function createHttpClient(dependencies) {
|
||||
const fetcher = dependencies.fetcher ?? fetch;
|
||||
const authSession = dependencies.authSession ?? noAuthSession;
|
||||
const clock = dependencies.clock ?? systemClock;
|
||||
const random = dependencies.random ?? Math.random;
|
||||
const validatePayload =
|
||||
dependencies.validatePayload ?? validateOperationPayload;
|
||||
const validateRequest =
|
||||
dependencies.validateRequest ?? validateOperationRequest;
|
||||
const mapPayload = dependencies.mapPayload ?? mapOperationPayload;
|
||||
const idempotencyKeyFactory =
|
||||
dependencies.idempotencyKeyFactory ?? (() => crypto.randomUUID());
|
||||
const defaultTimeoutMs = dependencies.timeoutMs ?? 10_000;
|
||||
const maxRetryAttempts = dependencies.maxRetryAttempts ?? 2;
|
||||
const selectOperation = dependencies.getOperation ?? getApiOperation;
|
||||
const diagnostics = dependencies.diagnostics;
|
||||
const telemetry = dependencies.telemetry;
|
||||
const correlationIdFactory =
|
||||
dependencies.correlationIdFactory ??
|
||||
(() => `request-${Math.floor(random() * 1_000_000).toString(36)}`);
|
||||
const scheduler =
|
||||
dependencies.scheduler ??
|
||||
/** @type {Scheduler} */ ({
|
||||
setTimeout: (callback, milliseconds) =>
|
||||
globalThis.setTimeout(callback, milliseconds),
|
||||
clearTimeout: (handle) =>
|
||||
globalThis.clearTimeout(
|
||||
/** @type {ReturnType<typeof setTimeout>} */ (handle),
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {string | OperationRequestInput} request
|
||||
* @param {{
|
||||
* body?: unknown,
|
||||
* routeId?: string,
|
||||
* pathParams?: Record<string, string | number>,
|
||||
* searchParams?: unknown,
|
||||
* signal?: AbortSignal,
|
||||
* idempotencyKey?: string,
|
||||
* correlationId?: string
|
||||
* }} [legacyInput]
|
||||
* @returns {Promise<HttpResult>}
|
||||
*/
|
||||
async function execute(request, legacyInput = {}) {
|
||||
const input =
|
||||
typeof request === "string"
|
||||
? {
|
||||
operationId: request,
|
||||
routeId: legacyInput.routeId ?? "UNSPECIFIED_ROUTE",
|
||||
pathParams: legacyInput.pathParams,
|
||||
searchParams: legacyInput.searchParams,
|
||||
body: legacyInput.body,
|
||||
signal: legacyInput.signal,
|
||||
idempotencyKey: legacyInput.idempotencyKey,
|
||||
correlationId: legacyInput.correlationId,
|
||||
}
|
||||
: request;
|
||||
const operation = selectOperation(input.operationId);
|
||||
const startedAt = clock.now();
|
||||
const correlationId = input.correlationId ?? correlationIdFactory();
|
||||
/**
|
||||
* @param {HttpResult} outcome
|
||||
* @param {"success" | "recovered" | "failed" | "aborted"} outcomeKind
|
||||
*/
|
||||
function finalize(outcome, outcomeKind) {
|
||||
const error = outcome.ok ? undefined : outcome.error;
|
||||
const context = {
|
||||
route_id: input.routeId,
|
||||
operation_id: input.operationId,
|
||||
correlation_id: correlationId,
|
||||
outcome: outcomeKind,
|
||||
error_kind: error?.kind ?? "NONE",
|
||||
http_status_group: statusGroup(error?.httpStatus),
|
||||
attempt_count_bucket: attemptBucket(
|
||||
error?.attemptCount ?? retryCount + 1,
|
||||
),
|
||||
duration_bucket: durationBucket(clock.now() - startedAt),
|
||||
};
|
||||
try {
|
||||
diagnostics?.record({
|
||||
level: error ? "warn" : "info",
|
||||
eventId: "http.request.completed",
|
||||
context,
|
||||
});
|
||||
} catch {
|
||||
// Diagnostics cannot change the HTTP result.
|
||||
}
|
||||
if (error && outcomeKind !== "aborted") {
|
||||
try {
|
||||
telemetry?.emit("api.request.failed", {
|
||||
error_kind: context.error_kind,
|
||||
http_status_group: context.http_status_group,
|
||||
attempt_count_bucket: context.attempt_count_bucket,
|
||||
route_id: context.route_id,
|
||||
operation_id: context.operation_id,
|
||||
duration_bucket: context.duration_bucket,
|
||||
});
|
||||
} catch {
|
||||
// Telemetry cannot change the HTTP result.
|
||||
}
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
const logicalIdempotencyKey =
|
||||
operation.idempotency === "keyed"
|
||||
? input.idempotencyKey ?? idempotencyKeyFactory()
|
||||
: undefined;
|
||||
let retryCount = 0;
|
||||
let recoveryUsed = false;
|
||||
|
||||
while (true) {
|
||||
const attempt = retryCount;
|
||||
/** @type {HttpResult} */
|
||||
const outcome = await performAttempt({
|
||||
operation,
|
||||
input,
|
||||
attempt,
|
||||
idempotencyKey: logicalIdempotencyKey,
|
||||
});
|
||||
|
||||
if (outcome.ok) {
|
||||
return finalize(
|
||||
outcome,
|
||||
retryCount > 0 || recoveryUsed ? "recovered" : "success",
|
||||
);
|
||||
}
|
||||
|
||||
if (outcome.error.httpStatus === 401 && !recoveryUsed) {
|
||||
recoveryUsed = true;
|
||||
const recovered = await recoverSession(
|
||||
authSession,
|
||||
operation,
|
||||
outcome.error,
|
||||
);
|
||||
if (!recovered.ok) return finalize(recovered, "failed");
|
||||
if (operation.idempotency === "none") {
|
||||
return finalize(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
...outcome.error,
|
||||
retryable: false,
|
||||
action: "retry",
|
||||
},
|
||||
},
|
||||
"failed",
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (outcome.error.httpStatus === 401 && recoveryUsed) {
|
||||
authSession.onUnauthenticated();
|
||||
return finalize(outcome, "failed");
|
||||
}
|
||||
|
||||
if (
|
||||
!shouldRetry(
|
||||
operation,
|
||||
outcome.error,
|
||||
retryCount,
|
||||
maxRetryAttempts,
|
||||
)
|
||||
) {
|
||||
return finalize(
|
||||
outcome,
|
||||
outcome.error.kind === "REQUEST_ABORTED" ? "aborted" : "failed",
|
||||
);
|
||||
}
|
||||
|
||||
const delay = retryDelay(outcome.error, retryCount, random, clock.now());
|
||||
retryCount += 1;
|
||||
|
||||
try {
|
||||
await clock.sleep(delay, input.signal);
|
||||
} catch {
|
||||
return finalize(
|
||||
{
|
||||
ok: false,
|
||||
error: failure("REQUEST_ABORTED", input.operationId, retryCount, {
|
||||
code: "REQUEST_ABORTED",
|
||||
}),
|
||||
},
|
||||
"aborted",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* operation: ReturnType<typeof getApiOperation>,
|
||||
* input: OperationRequestInput,
|
||||
* attempt: number,
|
||||
* idempotencyKey?: string
|
||||
* }} context
|
||||
* @returns {Promise<HttpResult>}
|
||||
*/
|
||||
async function performAttempt(context) {
|
||||
const { operation, input, attempt, idempotencyKey } = context;
|
||||
/** @type {unknown} */
|
||||
let parsedSearch = {};
|
||||
let parsedBody;
|
||||
const requestValue =
|
||||
operation.requestSource === "search"
|
||||
? input.searchParams ?? {}
|
||||
: operation.requestSource === "body"
|
||||
? input.body
|
||||
: {};
|
||||
if (operation.requestSource !== "none") {
|
||||
const requestValidation = validateRequest(
|
||||
operation.requestSchema,
|
||||
requestValue,
|
||||
);
|
||||
if (!requestValidation.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("VALIDATION_REJECTED", operation.operationId, attempt, {
|
||||
code: "REQUEST_SCHEMA_INVALID",
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (operation.requestSource === "search") {
|
||||
parsedSearch = requestValidation.data;
|
||||
} else {
|
||||
parsedBody = requestValidation.data;
|
||||
}
|
||||
}
|
||||
|
||||
const target = buildRequestTarget(
|
||||
dependencies.baseUrl,
|
||||
operation,
|
||||
input.pathParams,
|
||||
parsedSearch,
|
||||
);
|
||||
if (!target.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("VALIDATION_REJECTED", operation.operationId, attempt, {
|
||||
code: target.code,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const timeout = scheduler.setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort("timeout");
|
||||
}, operation.timeoutMs ?? defaultTimeoutMs);
|
||||
const onExternalAbort = () => controller.abort(input.signal?.reason);
|
||||
input.signal?.addEventListener("abort", onExternalAbort, { once: true });
|
||||
if (input.signal?.aborted) onExternalAbort();
|
||||
|
||||
const headers = new Headers({ Accept: "application/json" });
|
||||
if (parsedBody !== undefined) headers.set("Content-Type", "application/json");
|
||||
if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey);
|
||||
|
||||
let request = new Request(target.url, {
|
||||
method: operation.method,
|
||||
headers,
|
||||
body: parsedBody === undefined ? undefined : JSON.stringify(parsedBody),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
try {
|
||||
if (operation.auth === "external-session") {
|
||||
try {
|
||||
request = await authSession.attach(request);
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("AUTH_INTEGRATION_FAILURE", operation.operationId, attempt, {
|
||||
code: "AUTH_ATTACH_FAILED",
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetcher(request);
|
||||
return await parseResponse(
|
||||
response,
|
||||
operation,
|
||||
attempt,
|
||||
validatePayload,
|
||||
mapPayload,
|
||||
);
|
||||
} catch {
|
||||
if (timedOut) {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("REQUEST_TIMEOUT", operation.operationId, attempt, {
|
||||
code: "REQUEST_TIMEOUT",
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (controller.signal.aborted || input.signal?.aborted) {
|
||||
const externalReason = input.signal?.reason;
|
||||
if (externalReason === "timeout") {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("REQUEST_TIMEOUT", operation.operationId, attempt, {
|
||||
code: "REQUEST_TIMEOUT",
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (
|
||||
externalReason !== undefined &&
|
||||
!["navigation", "user", "superseded"].includes(String(externalReason))
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("UNKNOWN_FAILURE", operation.operationId, attempt, {
|
||||
code: "EXTERNAL_ABORT_UNRESOLVED",
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("REQUEST_ABORTED", operation.operationId, attempt, {
|
||||
code: "REQUEST_ABORTED",
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("NETWORK_UNREACHABLE", operation.operationId, attempt, {
|
||||
code: "NETWORK_UNREACHABLE",
|
||||
}),
|
||||
};
|
||||
} finally {
|
||||
scheduler.clearTimeout(timeout);
|
||||
input.signal?.removeEventListener("abort", onExternalAbort);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({ execute });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @param {import("../../contracts/api-operations.js").ApiOperation} operation
|
||||
* @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,
|
||||
mapPayload,
|
||||
) {
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (!contentType.toLowerCase().includes("application/json")) {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("CONTENT_TYPE_MISMATCH", operation.operationId, attempt, {
|
||||
code: "CONTENT_TYPE_MISMATCH",
|
||||
httpStatus: response.status,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
let envelope;
|
||||
try {
|
||||
envelope = await response.json();
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("MALFORMED_JSON", operation.operationId, attempt, {
|
||||
code: "MALFORMED_JSON",
|
||||
httpStatus: response.status,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const envelopeValidation = validateEnvelope(envelope);
|
||||
if (!envelopeValidation.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure(
|
||||
response.ok ? "ENVELOPE_MISMATCH" : statusKind(response.status),
|
||||
operation.operationId,
|
||||
attempt,
|
||||
{
|
||||
code: response.ok ? "ENVELOPE_MISMATCH" : "HTTP_FAILURE",
|
||||
httpStatus: response.status,
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const envelopeRecord =
|
||||
/** @type {Record<string, unknown>} */ (envelopeValidation.data);
|
||||
if (response.ok && envelopeRecord.success === true && "data" in envelopeRecord) {
|
||||
const payload = validatePayload(operation.responseSchema, envelopeRecord.data);
|
||||
if (!payload.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("SCHEMA_MISMATCH", operation.operationId, attempt, {
|
||||
code: "SCHEMA_MISMATCH",
|
||||
httpStatus: response.status,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
const retryAfter = response.headers.get("retry-after");
|
||||
const backendError =
|
||||
envelopeRecord.error && typeof envelopeRecord.error === "object"
|
||||
? /** @type {Record<string, unknown>} */ (envelopeRecord.error)
|
||||
: {};
|
||||
return {
|
||||
ok: false,
|
||||
error: failure(kind, operation.operationId, attempt, {
|
||||
code: safeBackendCode(envelope),
|
||||
httpStatus: response.status,
|
||||
requestId: safeMeta(envelopeRecord.meta).requestId,
|
||||
traceId: safeMeta(envelopeRecord.meta).traceId,
|
||||
retryAfterMs:
|
||||
response.status === 429 && retryAfter
|
||||
? parseRetryAfterHeader(retryAfter)
|
||||
: undefined,
|
||||
validationIssues:
|
||||
response.status === 422
|
||||
? safeValidationIssues(backendError.details)
|
||||
: undefined,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("../../application/ports/auth-session-port.js").AuthSessionPort} authSession
|
||||
* @param {import("../../contracts/api-operations.js").ApiOperation} operation
|
||||
* @param {HttpFailure} originalFailure
|
||||
* @returns {Promise<{ok: true} | {ok: false, error: HttpFailure}>}
|
||||
*/
|
||||
async function recoverSession(authSession, operation, originalFailure) {
|
||||
try {
|
||||
const result = await authSession.recover();
|
||||
if (result === "restored") return { ok: true };
|
||||
if (result === "no-session") {
|
||||
authSession.onUnauthenticated();
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("AUTH_REQUIRED", operation.operationId, originalFailure.attemptCount, {
|
||||
code: "AUTH_REQUIRED",
|
||||
httpStatus: 401,
|
||||
}),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Normalized below.
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: failure(
|
||||
"AUTH_INTEGRATION_FAILURE",
|
||||
operation.operationId,
|
||||
originalFailure.attemptCount,
|
||||
{ code: "AUTH_RECOVERY_FAILED" },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} kind
|
||||
* @param {string} operationId
|
||||
* @param {number} attempt
|
||||
* @param {FailureDetails} [details]
|
||||
* @returns {HttpFailure}
|
||||
*/
|
||||
/** @param {unknown} envelope */
|
||||
function safeBackendCode(envelope) {
|
||||
if (!envelope || typeof envelope !== "object") return "HTTP_FAILURE";
|
||||
const error = /** @type {Record<string, unknown>} */ (envelope).error;
|
||||
if (!error || typeof error !== "object") return "HTTP_FAILURE";
|
||||
const code = /** @type {Record<string, unknown>} */ (error).code;
|
||||
return typeof code === "string" ? code : "HTTP_FAILURE";
|
||||
}
|
||||
|
||||
/** @param {unknown} meta @returns {Record<string, string>} */
|
||||
function safeMeta(meta) {
|
||||
if (!meta || typeof meta !== "object") return {};
|
||||
const metaRecord = /** @type {Record<string, unknown>} */ (meta);
|
||||
return {
|
||||
...(typeof metaRecord.requestId === "string"
|
||||
? { requestId: metaRecord.requestId }
|
||||
: {}),
|
||||
...(typeof metaRecord.traceId === "string" ? { traceId: metaRecord.traceId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** @param {string} value */
|
||||
function parseRetryAfterHeader(value) {
|
||||
const seconds = Number(value);
|
||||
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1_000;
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isFinite(timestamp) ? Math.max(0, timestamp - Date.now()) : undefined;
|
||||
}
|
||||
@@ -0,0 +1,944 @@
|
||||
import { systemClock } from "../platform/system-clock.ts";
|
||||
import { getApiOperation } from "../../contracts/api-operations.ts";
|
||||
import {
|
||||
createFailure as failure,
|
||||
kindForStatus as statusKind,
|
||||
safeValidationIssues,
|
||||
} from "../../contracts/errors.ts";
|
||||
import { mapOperationPayload } from "./resource-mapper.ts";
|
||||
import { retryDelay, shouldRetry } from "./retry-policy.ts";
|
||||
import {
|
||||
validateEnvelope,
|
||||
validateOperationPayload,
|
||||
validateOperationRequest,
|
||||
} from "./schema-registry.ts";
|
||||
import { buildRequestTarget } from "./request-builder.ts";
|
||||
import {
|
||||
attemptBucket,
|
||||
durationBucket,
|
||||
statusGroup,
|
||||
} from "../../contracts/diagnostics.ts";
|
||||
import type { AuthSessionPort } from "../../application/ports/auth-session-port.ts";
|
||||
import type { ClockPort } from "../../application/ports/clock-port.ts";
|
||||
import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts";
|
||||
import type { TelemetryPort } from "../../application/ports/telemetry-port.ts";
|
||||
import type { ApiOperation } from "../../contracts/api-operations.ts";
|
||||
import type { ApiFailure } from "../../contracts/errors.ts";
|
||||
import type { OperationRequestInput } from "./request-builder.ts";
|
||||
import { readBoundedJson } from "./bounded-json.ts";
|
||||
import type { MappingResult } from "../../contracts/boundary-mapper.ts";
|
||||
import {
|
||||
createRestProviderProfile,
|
||||
resolveRestSecurityProfiles,
|
||||
REST_AUTH_PROFILES,
|
||||
REST_CSRF_PROFILES,
|
||||
type RestAuthProfile,
|
||||
type RestCsrfProfile,
|
||||
type RestProviderProfile,
|
||||
} from "../../contracts/rest-profiles.ts";
|
||||
|
||||
type HttpAuthSession = Pick<
|
||||
AuthSessionPort,
|
||||
"getState" | "credentialPatch" | "recover" | "onUnauthenticated"
|
||||
>;
|
||||
|
||||
const noAuthSession: HttpAuthSession = Object.freeze({
|
||||
getState: () => "integration-failed",
|
||||
credentialPatch: async () => {
|
||||
throw new TypeError("Auth session is not installed");
|
||||
},
|
||||
recover: async () => "no-session",
|
||||
onUnauthenticated: () => {},
|
||||
} satisfies HttpAuthSession);
|
||||
|
||||
export type HttpFailure = ApiFailure;
|
||||
|
||||
export type Scheduler = Readonly<{
|
||||
setTimeout(callback: () => void, milliseconds: number): unknown;
|
||||
clearTimeout(handle: unknown): void;
|
||||
}>;
|
||||
|
||||
export type HttpResult =
|
||||
| Readonly<{
|
||||
ok: true;
|
||||
value: unknown;
|
||||
meta: Readonly<Record<string, string>>;
|
||||
}>
|
||||
| Readonly<{ ok: false; error: HttpFailure }>;
|
||||
|
||||
type SchemaValidator = (
|
||||
schemaId: string,
|
||||
value: unknown,
|
||||
) =>
|
||||
| Readonly<{ success: true; data: unknown }>
|
||||
| Readonly<{ success: false }>;
|
||||
|
||||
export type HttpClientDependencies = Readonly<{
|
||||
baseUrl: string;
|
||||
fetcher?: typeof fetch;
|
||||
authSession?: AuthSessionPort;
|
||||
clock?: ClockPort;
|
||||
random?: () => number;
|
||||
validatePayload?: SchemaValidator;
|
||||
validateRequest?: SchemaValidator;
|
||||
validatePath?: SchemaValidator;
|
||||
mapPayload?: (
|
||||
operationId: string,
|
||||
payload: unknown,
|
||||
) => MappingResult<unknown>;
|
||||
idempotencyKeyFactory?: () => string;
|
||||
timeoutMs?: number;
|
||||
maxRetryAttempts?: number;
|
||||
scheduler?: Scheduler;
|
||||
getOperation?: typeof getApiOperation;
|
||||
diagnostics?: DiagnosticsPort;
|
||||
telemetry?: TelemetryPort;
|
||||
correlationIdFactory?: () => string;
|
||||
providerProfile?: RestProviderProfile;
|
||||
authProfiles?: Readonly<Record<string, RestAuthProfile>>;
|
||||
csrfProfiles?: Readonly<Record<string, RestCsrfProfile>>;
|
||||
maxCumulativeSleepMs?: number;
|
||||
}>;
|
||||
|
||||
export type LegacyHttpInput = Readonly<{
|
||||
body?: unknown;
|
||||
routeId?: string;
|
||||
pathParams?: Readonly<Record<string, string | number>>;
|
||||
searchParams?: unknown;
|
||||
signal?: AbortSignal;
|
||||
idempotencyKey?: string;
|
||||
correlationId?: string;
|
||||
}>;
|
||||
|
||||
export type HttpClient = Readonly<{
|
||||
execute(
|
||||
request: string | OperationRequestInput,
|
||||
legacyInput?: LegacyHttpInput,
|
||||
): Promise<HttpResult>;
|
||||
}>;
|
||||
|
||||
export function createHttpClient(
|
||||
dependencies: HttpClientDependencies,
|
||||
): HttpClient {
|
||||
const fetcher = dependencies.fetcher ?? fetch;
|
||||
const authSession = dependencies.authSession ?? noAuthSession;
|
||||
const clock = dependencies.clock ?? systemClock;
|
||||
const random = dependencies.random ?? Math.random;
|
||||
const validatePayload =
|
||||
dependencies.validatePayload ?? validateOperationPayload;
|
||||
const validateRequest =
|
||||
dependencies.validateRequest ?? validateOperationRequest;
|
||||
const validatePath = dependencies.validatePath ?? validateRequest;
|
||||
const mapPayload = dependencies.mapPayload ?? mapOperationPayload;
|
||||
const idempotencyKeyFactory =
|
||||
dependencies.idempotencyKeyFactory ?? (() => crypto.randomUUID());
|
||||
const defaultTimeoutMs = dependencies.timeoutMs ?? 10_000;
|
||||
const maxRetryAttempts = dependencies.maxRetryAttempts ?? 2;
|
||||
const maxCumulativeSleepMs =
|
||||
dependencies.maxCumulativeSleepMs ?? defaultTimeoutMs;
|
||||
const selectOperation = dependencies.getOperation ?? getApiOperation;
|
||||
const diagnostics = dependencies.diagnostics;
|
||||
const telemetry = dependencies.telemetry;
|
||||
const correlationIdFactory =
|
||||
dependencies.correlationIdFactory ??
|
||||
(() => `request-${Math.floor(random() * 1_000_000).toString(36)}`);
|
||||
const scheduler =
|
||||
dependencies.scheduler ??
|
||||
({
|
||||
setTimeout: (callback, milliseconds) =>
|
||||
globalThis.setTimeout(callback, milliseconds),
|
||||
clearTimeout: (handle) =>
|
||||
globalThis.clearTimeout(
|
||||
handle as ReturnType<typeof globalThis.setTimeout>,
|
||||
),
|
||||
} satisfies Scheduler);
|
||||
|
||||
async function execute(
|
||||
request: string | OperationRequestInput,
|
||||
legacyInput: LegacyHttpInput = {},
|
||||
): Promise<HttpResult> {
|
||||
const input =
|
||||
typeof request === "string"
|
||||
? {
|
||||
operationId: request,
|
||||
routeId: legacyInput.routeId ?? "UNSPECIFIED_ROUTE",
|
||||
pathParams: legacyInput.pathParams,
|
||||
searchParams: legacyInput.searchParams,
|
||||
body: legacyInput.body,
|
||||
signal: legacyInput.signal,
|
||||
idempotencyKey: legacyInput.idempotencyKey,
|
||||
correlationId: legacyInput.correlationId,
|
||||
}
|
||||
: request;
|
||||
const startedAt = clock.now();
|
||||
let correlationId: string;
|
||||
try {
|
||||
correlationId = correlationIdValue(
|
||||
input.correlationId ?? correlationIdFactory(),
|
||||
);
|
||||
} catch {
|
||||
correlationId = "client-generated";
|
||||
}
|
||||
let operation: ApiOperation;
|
||||
try {
|
||||
operation = selectOperation(input.operationId);
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("UNKNOWN_CLIENT_FAILURE", input.operationId, 0, {
|
||||
code: "OPERATION_NOT_REGISTERED",
|
||||
}),
|
||||
};
|
||||
}
|
||||
const totalDeadlineMs = operation.timeoutMs ?? defaultTimeoutMs;
|
||||
const deadlineAt = startedAt + totalDeadlineMs;
|
||||
let physicalAttemptCount = 0;
|
||||
function finalize(
|
||||
outcome: HttpResult,
|
||||
outcomeKind: "success" | "recovered" | "failed" | "aborted",
|
||||
): HttpResult {
|
||||
const error = outcome.ok ? undefined : outcome.error;
|
||||
const context = {
|
||||
route_id: input.routeId,
|
||||
operation_id: input.operationId,
|
||||
correlation_id: correlationId,
|
||||
outcome: outcomeKind,
|
||||
error_kind: error?.kind ?? "NONE",
|
||||
http_status_group: statusGroup(
|
||||
error?.httpStatus ??
|
||||
(outcome.ok ? Number(outcome.meta.httpStatus) : undefined),
|
||||
),
|
||||
attempt_count_bucket: attemptBucket(
|
||||
error?.attemptCount ?? Math.max(1, physicalAttemptCount),
|
||||
),
|
||||
duration_bucket: durationBucket(clock.now() - startedAt),
|
||||
};
|
||||
try {
|
||||
diagnostics?.record({
|
||||
level: error ? "warn" : "info",
|
||||
eventId: "http.request.completed",
|
||||
context,
|
||||
});
|
||||
} catch {
|
||||
// Diagnostics cannot change the HTTP result.
|
||||
}
|
||||
if (error && outcomeKind !== "aborted") {
|
||||
try {
|
||||
telemetry?.emit("api.request.failed", {
|
||||
error_kind: context.error_kind,
|
||||
http_status_group: context.http_status_group,
|
||||
attempt_count_bucket: context.attempt_count_bucket,
|
||||
route_id: context.route_id,
|
||||
operation_id: context.operation_id,
|
||||
duration_bucket: context.duration_bucket,
|
||||
});
|
||||
} catch {
|
||||
// Telemetry cannot change the HTTP result.
|
||||
}
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
let logicalIdempotencyKey: string | undefined;
|
||||
try {
|
||||
logicalIdempotencyKey =
|
||||
operation.idempotency === "keyed"
|
||||
? input.idempotencyKey ?? idempotencyKeyFactory()
|
||||
: undefined;
|
||||
} catch {
|
||||
return finalize(
|
||||
{
|
||||
ok: false,
|
||||
error: failure("UNKNOWN_CLIENT_FAILURE", input.operationId, 0, {
|
||||
code: "IDEMPOTENCY_KEY_CREATION_FAILED",
|
||||
}),
|
||||
},
|
||||
"failed",
|
||||
);
|
||||
}
|
||||
let retryCount = 0;
|
||||
let recoveryUsed = false;
|
||||
let cumulativeSleepMs = 0;
|
||||
|
||||
while (true) {
|
||||
const attempt = physicalAttemptCount;
|
||||
physicalAttemptCount += 1;
|
||||
let outcome: HttpResult;
|
||||
try {
|
||||
outcome = await performAttempt({
|
||||
operation,
|
||||
input,
|
||||
attempt,
|
||||
idempotencyKey: logicalIdempotencyKey,
|
||||
deadlineAt,
|
||||
correlationId,
|
||||
});
|
||||
} catch {
|
||||
outcome = {
|
||||
ok: false,
|
||||
error: failure("UNKNOWN_CLIENT_FAILURE", input.operationId, attempt, {
|
||||
code: "HTTP_EXECUTION_CONTRACT_VIOLATION",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (outcome.ok) {
|
||||
return finalize(
|
||||
outcome,
|
||||
retryCount > 0 || recoveryUsed ? "recovered" : "success",
|
||||
);
|
||||
}
|
||||
|
||||
if (outcome.error.httpStatus === 401 && !recoveryUsed) {
|
||||
recoveryUsed = true;
|
||||
if (physicalAttemptCount >= maxRetryAttempts + 1) {
|
||||
authSession.onUnauthenticated();
|
||||
return finalize(outcome, "failed");
|
||||
}
|
||||
let recovered: Awaited<ReturnType<typeof recoverSession>>;
|
||||
try {
|
||||
recovered = await withinLogicalDeadline(
|
||||
recoverSession(authSession, operation, outcome.error),
|
||||
deadlineAt,
|
||||
input.signal,
|
||||
);
|
||||
} catch (error) {
|
||||
return finalize(
|
||||
{
|
||||
ok: false,
|
||||
error: failure(
|
||||
error instanceof LogicalDeadlineError
|
||||
? "REQUEST_TIMEOUT"
|
||||
: "REQUEST_ABORTED",
|
||||
operation.operationId,
|
||||
attempt,
|
||||
{
|
||||
code:
|
||||
error instanceof LogicalDeadlineError
|
||||
? "OPERATION_DEADLINE_EXCEEDED"
|
||||
: "REQUEST_ABORTED",
|
||||
},
|
||||
),
|
||||
},
|
||||
error instanceof LogicalDeadlineError ? "failed" : "aborted",
|
||||
);
|
||||
}
|
||||
if (!recovered.ok) return finalize(recovered, "failed");
|
||||
if (operation.idempotency === "none") {
|
||||
return finalize(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
...outcome.error,
|
||||
retryable: false,
|
||||
action: "retry",
|
||||
},
|
||||
},
|
||||
"failed",
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (outcome.error.httpStatus === 401 && recoveryUsed) {
|
||||
authSession.onUnauthenticated();
|
||||
return finalize(outcome, "failed");
|
||||
}
|
||||
|
||||
if (
|
||||
!shouldRetry(
|
||||
operation,
|
||||
outcome.error,
|
||||
retryCount,
|
||||
maxRetryAttempts,
|
||||
)
|
||||
) {
|
||||
return finalize(
|
||||
outcome,
|
||||
outcome.error.kind === "REQUEST_ABORTED" ? "aborted" : "failed",
|
||||
);
|
||||
}
|
||||
|
||||
const delay = retryDelay(outcome.error, retryCount, random, clock.now());
|
||||
retryCount += 1;
|
||||
cumulativeSleepMs += delay;
|
||||
if (
|
||||
clock.now() + delay >= deadlineAt ||
|
||||
cumulativeSleepMs > maxCumulativeSleepMs
|
||||
) {
|
||||
return finalize(
|
||||
{
|
||||
ok: false,
|
||||
error: failure("REQUEST_TIMEOUT", input.operationId, attempt, {
|
||||
code: "OPERATION_DEADLINE_EXCEEDED",
|
||||
}),
|
||||
},
|
||||
"failed",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await clock.sleep(delay, input.signal);
|
||||
} catch {
|
||||
return finalize(
|
||||
{
|
||||
ok: false,
|
||||
error: failure("REQUEST_ABORTED", input.operationId, retryCount, {
|
||||
code: "REQUEST_ABORTED",
|
||||
}),
|
||||
},
|
||||
"aborted",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function performAttempt(
|
||||
context: Readonly<{
|
||||
operation: ApiOperation;
|
||||
input: OperationRequestInput;
|
||||
attempt: number;
|
||||
idempotencyKey?: string;
|
||||
deadlineAt: number;
|
||||
correlationId: string;
|
||||
}>,
|
||||
): Promise<HttpResult> {
|
||||
const {
|
||||
operation,
|
||||
input,
|
||||
attempt,
|
||||
idempotencyKey,
|
||||
deadlineAt,
|
||||
correlationId,
|
||||
} = context;
|
||||
if (clock.now() >= deadlineAt) {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("REQUEST_TIMEOUT", operation.operationId, attempt, {
|
||||
code: "OPERATION_DEADLINE_EXCEEDED",
|
||||
}),
|
||||
};
|
||||
}
|
||||
let parsedSearch: unknown = {};
|
||||
let parsedBody: unknown;
|
||||
let parsedPath: Readonly<Record<string, string | number>> =
|
||||
input.pathParams ?? {};
|
||||
if (operation.pathSchema) {
|
||||
const pathValidation = validatePath(
|
||||
operation.pathSchema,
|
||||
input.pathParams ?? {},
|
||||
);
|
||||
if (
|
||||
!pathValidation.success ||
|
||||
!isPathParameterRecord(pathValidation.data)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure(
|
||||
"VALIDATION_REJECTED",
|
||||
operation.operationId,
|
||||
attempt,
|
||||
{ code: "PATH_SCHEMA_INVALID" },
|
||||
),
|
||||
};
|
||||
}
|
||||
parsedPath = pathValidation.data;
|
||||
}
|
||||
const requestValue =
|
||||
operation.requestSource === "search"
|
||||
? input.searchParams ?? {}
|
||||
: operation.requestSource === "body"
|
||||
? input.body
|
||||
: {};
|
||||
if (operation.requestSource !== "none") {
|
||||
const requestValidation = validateRequest(
|
||||
operation.requestSchema,
|
||||
requestValue,
|
||||
);
|
||||
if (!requestValidation.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("VALIDATION_REJECTED", operation.operationId, attempt, {
|
||||
code: "REQUEST_SCHEMA_INVALID",
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (operation.requestSource === "search") {
|
||||
parsedSearch = requestValidation.data;
|
||||
} else {
|
||||
parsedBody = requestValidation.data;
|
||||
}
|
||||
}
|
||||
|
||||
let target: ReturnType<typeof buildRequestTarget>;
|
||||
let provider: RestProviderProfile | null = null;
|
||||
let security:
|
||||
| ReturnType<typeof resolveRestSecurityProfiles>
|
||||
| undefined;
|
||||
try {
|
||||
provider =
|
||||
dependencies.providerProfile ??
|
||||
createRestProviderProfile(
|
||||
operation.providerId ?? "LEGACY_API",
|
||||
dependencies.baseUrl,
|
||||
["omit", "same-origin"],
|
||||
);
|
||||
if (
|
||||
operation.contractVersion === 2 &&
|
||||
operation.providerId !== provider.providerId
|
||||
) {
|
||||
throw new TypeError("REST provider binding mismatch.");
|
||||
}
|
||||
if (operation.contractVersion === 2) {
|
||||
security = resolveRestSecurityProfiles(
|
||||
operation,
|
||||
provider,
|
||||
dependencies.authProfiles ?? REST_AUTH_PROFILES,
|
||||
dependencies.csrfProfiles ?? REST_CSRF_PROFILES,
|
||||
);
|
||||
}
|
||||
target = buildRequestTarget(
|
||||
provider.baseUrl,
|
||||
operation,
|
||||
parsedPath,
|
||||
parsedSearch,
|
||||
);
|
||||
} catch {
|
||||
target = { success: false, code: "BASE_URL_INVALID" };
|
||||
}
|
||||
if (!target.success || !provider) {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("VALIDATION_REJECTED", operation.operationId, attempt, {
|
||||
code: target.success ? "BASE_URL_INVALID" : target.code,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const remainingMs = Math.max(1, deadlineAt - clock.now());
|
||||
const timeout = scheduler.setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort("timeout");
|
||||
}, remainingMs);
|
||||
const onExternalAbort = () => controller.abort(input.signal?.reason);
|
||||
input.signal?.addEventListener("abort", onExternalAbort, { once: true });
|
||||
if (input.signal?.aborted) onExternalAbort();
|
||||
|
||||
try {
|
||||
const headers = new Headers({
|
||||
Accept: operation.responseMediaTypes?.join(", ") ?? "application/json",
|
||||
"X-Correlation-ID": correlationIdValue(correlationId),
|
||||
});
|
||||
if (parsedBody !== undefined) headers.set("Content-Type", "application/json");
|
||||
if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey);
|
||||
|
||||
if (operation.auth === "external-session") {
|
||||
let sessionState: ReturnType<HttpAuthSession["getState"]>;
|
||||
try {
|
||||
sessionState = authSession.getState();
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure(
|
||||
"AUTH_INTEGRATION_FAILURE",
|
||||
operation.operationId,
|
||||
attempt,
|
||||
{ code: "AUTH_STATE_FAILED" },
|
||||
),
|
||||
};
|
||||
}
|
||||
if (sessionState === "unauthenticated") {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("AUTH_REQUIRED", operation.operationId, attempt, {
|
||||
code: "AUTH_REQUIRED",
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (sessionState !== "authenticated") {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure(
|
||||
"AUTH_INTEGRATION_FAILURE",
|
||||
operation.operationId,
|
||||
attempt,
|
||||
{ code: "AUTH_SESSION_UNAVAILABLE" },
|
||||
),
|
||||
};
|
||||
}
|
||||
try {
|
||||
const patch = await authSession.credentialPatch({
|
||||
origin: target.url.origin,
|
||||
method: operation.method,
|
||||
operationId: operation.operationId,
|
||||
});
|
||||
for (const [name, value] of Object.entries(patch.headers)) {
|
||||
const normalized = name.toLowerCase();
|
||||
const allowedHeaders =
|
||||
security?.auth.allowedCredentialHeaders ??
|
||||
(["authorization", "x-csrf-token"] as const);
|
||||
if (!allowedHeaders.includes(normalized as never)) {
|
||||
throw new TypeError("Credential patch contains a forbidden header");
|
||||
}
|
||||
headers.set(normalized, value);
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("AUTH_INTEGRATION_FAILURE", operation.operationId, attempt, {
|
||||
code: "AUTH_ATTACH_FAILED",
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const request = new Request(target.url, {
|
||||
method: operation.method,
|
||||
headers,
|
||||
body: parsedBody === undefined ? undefined : JSON.stringify(parsedBody),
|
||||
signal: controller.signal,
|
||||
credentials: security?.auth.credentials ?? "same-origin",
|
||||
cache: "no-store",
|
||||
redirect: provider.redirect,
|
||||
referrerPolicy: provider.referrerPolicy,
|
||||
});
|
||||
const response = await fetcher(request);
|
||||
return await parseResponse(
|
||||
response,
|
||||
operation,
|
||||
attempt,
|
||||
validatePayload,
|
||||
mapPayload,
|
||||
clock.now(),
|
||||
);
|
||||
} catch {
|
||||
if (timedOut) {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("REQUEST_TIMEOUT", operation.operationId, attempt, {
|
||||
code: "REQUEST_TIMEOUT",
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (controller.signal.aborted || input.signal?.aborted) {
|
||||
const externalReason = input.signal?.reason;
|
||||
if (externalReason === "timeout") {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("REQUEST_TIMEOUT", operation.operationId, attempt, {
|
||||
code: "REQUEST_TIMEOUT",
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (
|
||||
externalReason !== undefined &&
|
||||
!["navigation", "user", "superseded"].includes(String(externalReason))
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("UNKNOWN_FAILURE", operation.operationId, attempt, {
|
||||
code: "EXTERNAL_ABORT_UNRESOLVED",
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("REQUEST_ABORTED", operation.operationId, attempt, {
|
||||
code: "REQUEST_ABORTED",
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("NETWORK_UNREACHABLE", operation.operationId, attempt, {
|
||||
code: "NETWORK_UNREACHABLE",
|
||||
}),
|
||||
};
|
||||
} finally {
|
||||
scheduler.clearTimeout(timeout);
|
||||
input.signal?.removeEventListener("abort", onExternalAbort);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({ execute });
|
||||
|
||||
function withinLogicalDeadline<Value>(
|
||||
promise: Promise<Value>,
|
||||
deadlineAt: number,
|
||||
externalSignal: AbortSignal | undefined,
|
||||
): Promise<Value> {
|
||||
const remaining = deadlineAt - clock.now();
|
||||
if (remaining <= 0) return Promise.reject(new LogicalDeadlineError());
|
||||
return new Promise<Value>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timeout = scheduler.setTimeout(
|
||||
() => settle(() => reject(new LogicalDeadlineError())),
|
||||
remaining,
|
||||
);
|
||||
const onAbort = () =>
|
||||
settle(() => reject(new DOMException("Aborted", "AbortError")));
|
||||
externalSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
const settle = (complete: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
scheduler.clearTimeout(timeout);
|
||||
externalSignal?.removeEventListener("abort", onAbort);
|
||||
complete();
|
||||
};
|
||||
if (externalSignal?.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
promise.then(
|
||||
(value) => settle(() => resolve(value)),
|
||||
(error: unknown) => settle(() => reject(error)),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class LogicalDeadlineError extends Error {}
|
||||
|
||||
async function parseResponse(
|
||||
response: Response,
|
||||
operation: ApiOperation,
|
||||
attempt: number,
|
||||
validatePayload: SchemaValidator,
|
||||
mapPayload: (
|
||||
operationId: string,
|
||||
payload: unknown,
|
||||
) => MappingResult<unknown>,
|
||||
now: number,
|
||||
): Promise<HttpResult> {
|
||||
const contentType = mediaType(response.headers.get("content-type"));
|
||||
const acceptedMedia = operation.responseMediaTypes ?? ["application/json"];
|
||||
if (!contentType || !acceptedMedia.includes(contentType)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("CONTENT_TYPE_MISMATCH", operation.operationId, attempt, {
|
||||
code: "CONTENT_TYPE_MISMATCH",
|
||||
httpStatus: response.status,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const decoded = await readBoundedJson(
|
||||
response,
|
||||
operation.maxResponseBytes ?? 1_048_576,
|
||||
);
|
||||
if (!decoded.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure(decoded.code, operation.operationId, attempt, {
|
||||
code: decoded.code,
|
||||
httpStatus: response.status,
|
||||
}),
|
||||
};
|
||||
}
|
||||
const envelope = decoded.value;
|
||||
|
||||
const envelopeValidation = validateEnvelope(envelope);
|
||||
if (!envelopeValidation.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure(
|
||||
response.ok ? "ENVELOPE_MISMATCH" : statusKind(response.status),
|
||||
operation.operationId,
|
||||
attempt,
|
||||
{
|
||||
code: response.ok ? "ENVELOPE_MISMATCH" : "HTTP_FAILURE",
|
||||
httpStatus: response.status,
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const envelopeRecord = envelopeValidation.data as Record<string, unknown>;
|
||||
const successStatus = operation.successStatuses
|
||||
? operation.successStatuses.includes(response.status)
|
||||
: response.ok;
|
||||
if (successStatus && envelopeRecord.success === true && "data" in envelopeRecord) {
|
||||
const payload = validatePayload(operation.responseSchema, envelopeRecord.data);
|
||||
if (!payload.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("SCHEMA_MISMATCH", operation.operationId, attempt, {
|
||||
code: "SCHEMA_MISMATCH",
|
||||
httpStatus: response.status,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const mapped = mapPayload(operation.operationId, payload.data);
|
||||
if (!mapped.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure(
|
||||
"MAPPING_CONTRACT_VIOLATION",
|
||||
operation.operationId,
|
||||
attempt,
|
||||
{
|
||||
code: mapped.code,
|
||||
httpStatus: response.status,
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: mapped.value,
|
||||
meta: {
|
||||
...safeMeta(envelopeRecord.meta),
|
||||
httpStatus: String(response.status),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure(
|
||||
"MAPPING_CONTRACT_VIOLATION",
|
||||
operation.operationId,
|
||||
attempt,
|
||||
{
|
||||
code: "MAPPING_CONTRACT_VIOLATION",
|
||||
httpStatus: response.status,
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
if (successStatus !== response.ok || (successStatus && envelopeRecord.success !== true)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("ENVELOPE_MISMATCH", operation.operationId, attempt, {
|
||||
code: "STATUS_ENVELOPE_MISMATCH",
|
||||
httpStatus: response.status,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const kind = statusKind(response.status);
|
||||
const retryAfter = response.headers.get("retry-after");
|
||||
const backendError =
|
||||
envelopeRecord.error && typeof envelopeRecord.error === "object"
|
||||
? (envelopeRecord.error as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
ok: false,
|
||||
error: failure(kind, operation.operationId, attempt, {
|
||||
code: safeBackendCode(envelope),
|
||||
httpStatus: response.status,
|
||||
requestId: safeMeta(envelopeRecord.meta).requestId,
|
||||
traceId: safeMeta(envelopeRecord.meta).traceId,
|
||||
retryAfterMs:
|
||||
response.status === 429 && retryAfter
|
||||
? parseRetryAfterHeader(retryAfter, now)
|
||||
: undefined,
|
||||
validationIssues:
|
||||
response.status === 422
|
||||
? safeValidationIssues(backendError.details)
|
||||
: undefined,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function recoverSession(
|
||||
authSession: HttpAuthSession,
|
||||
operation: ApiOperation,
|
||||
originalFailure: HttpFailure,
|
||||
): Promise<
|
||||
Readonly<{ ok: true }> | Readonly<{ ok: false; error: HttpFailure }>
|
||||
> {
|
||||
try {
|
||||
const result = await authSession.recover();
|
||||
if (result === "restored") return { ok: true };
|
||||
if (result === "no-session") {
|
||||
authSession.onUnauthenticated();
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("AUTH_REQUIRED", operation.operationId, originalFailure.attemptCount - 1, {
|
||||
code: "AUTH_REQUIRED",
|
||||
httpStatus: 401,
|
||||
}),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Normalized below.
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: failure(
|
||||
"AUTH_INTEGRATION_FAILURE",
|
||||
operation.operationId,
|
||||
originalFailure.attemptCount - 1,
|
||||
{ code: "AUTH_RECOVERY_FAILED" },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function safeBackendCode(envelope: unknown): string {
|
||||
if (!envelope || typeof envelope !== "object") return "HTTP_FAILURE";
|
||||
const error = (envelope as Record<string, unknown>).error;
|
||||
if (!error || typeof error !== "object") return "HTTP_FAILURE";
|
||||
const code = (error as Record<string, unknown>).code;
|
||||
return typeof code === "string" && /^[A-Z0-9_]{1,64}$/.test(code)
|
||||
? code
|
||||
: "HTTP_FAILURE";
|
||||
}
|
||||
|
||||
function safeMeta(meta: unknown): Record<string, string> {
|
||||
if (!meta || typeof meta !== "object") return {};
|
||||
const metaRecord = meta as Record<string, unknown>;
|
||||
return {
|
||||
...(typeof metaRecord.requestId === "string"
|
||||
? safeIdentifier(metaRecord.requestId, "requestId")
|
||||
: {}),
|
||||
...(typeof metaRecord.traceId === "string"
|
||||
? safeIdentifier(metaRecord.traceId, "traceId")
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseRetryAfterHeader(value: string, now: number): number | undefined {
|
||||
const seconds = Number(value);
|
||||
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1_000;
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isFinite(timestamp) ? Math.max(0, timestamp - now) : undefined;
|
||||
}
|
||||
|
||||
function safeIdentifier(
|
||||
value: string,
|
||||
property: "requestId" | "traceId",
|
||||
): Record<string, string> {
|
||||
return /^[A-Za-z0-9._:-]{1,128}$/.test(value) ? { [property]: value } : {};
|
||||
}
|
||||
|
||||
function mediaType(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
const selected = value.split(";", 1)[0]?.trim().toLowerCase();
|
||||
return selected && /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(selected)
|
||||
? selected
|
||||
: null;
|
||||
}
|
||||
|
||||
function correlationIdValue(value: string | undefined): string {
|
||||
return value && /^[A-Za-z0-9._:-]{1,128}$/.test(value)
|
||||
? value
|
||||
: "client-generated";
|
||||
}
|
||||
|
||||
function isPathParameterRecord(
|
||||
value: unknown,
|
||||
): value is Readonly<Record<string, string | number>> {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
Object.values(value).every(
|
||||
(item) => typeof item === "string" || typeof item === "number",
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ApiOperation } from "../../contracts/api-operations.js";
|
||||
import type { ApiOperation } from "../../contracts/api-operations.ts";
|
||||
|
||||
export type OperationRequestInput = Readonly<{
|
||||
operationId: string;
|
||||
@@ -15,7 +15,12 @@ export type RequestTargetResult =
|
||||
| Readonly<{ success: true; url: URL }>
|
||||
| Readonly<{
|
||||
success: false;
|
||||
code: "PATH_PARAMETER_MISSING" | "SEARCH_PARAMETER_INVALID";
|
||||
code:
|
||||
| "BASE_URL_INVALID"
|
||||
| "PATH_PARAMETER_MISSING"
|
||||
| "PATH_PARAMETER_UNEXPECTED"
|
||||
| "PATH_PARAMETER_INVALID"
|
||||
| "SEARCH_PARAMETER_INVALID";
|
||||
}>;
|
||||
|
||||
const pathParameterPattern = /:([A-Za-z][A-Za-z0-9_]*)|\{([A-Za-z][A-Za-z0-9_]*)\}/g;
|
||||
@@ -26,7 +31,35 @@ export function buildRequestTarget(
|
||||
pathParams: Readonly<Record<string, string | number>> = {},
|
||||
parsedSearch: unknown = {},
|
||||
): RequestTargetResult {
|
||||
let base: URL;
|
||||
try {
|
||||
base = new URL(baseUrl);
|
||||
} catch {
|
||||
return { success: false, code: "BASE_URL_INVALID" };
|
||||
}
|
||||
if (
|
||||
(base.protocol !== "https:" &&
|
||||
!(
|
||||
base.protocol === "http:" &&
|
||||
["localhost", "127.0.0.1", "[::1]"].includes(base.hostname)
|
||||
)) ||
|
||||
base.username ||
|
||||
base.password ||
|
||||
base.search ||
|
||||
base.hash
|
||||
) {
|
||||
return { success: false, code: "BASE_URL_INVALID" };
|
||||
}
|
||||
|
||||
const placeholders = new Set<string>();
|
||||
for (const match of operation.path.matchAll(pathParameterPattern)) {
|
||||
placeholders.add(match[1] ?? match[2] ?? "");
|
||||
}
|
||||
if (Object.keys(pathParams).some((key) => !placeholders.has(key))) {
|
||||
return { success: false, code: "PATH_PARAMETER_UNEXPECTED" };
|
||||
}
|
||||
let missingPathParameter = false;
|
||||
let invalidPathParameter = false;
|
||||
const pathname = operation.path.replace(
|
||||
pathParameterPattern,
|
||||
(_token, colonName: string | undefined, braceName: string | undefined) => {
|
||||
@@ -36,12 +69,27 @@ export function buildRequestTarget(
|
||||
missingPathParameter = true;
|
||||
return "";
|
||||
}
|
||||
return encodeURIComponent(String(value));
|
||||
const serialized = String(value);
|
||||
if (
|
||||
serialized.length === 0 ||
|
||||
serialized.length > 512 ||
|
||||
[...serialized].some((character) => {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
return code < 32 || code === 127;
|
||||
})
|
||||
) {
|
||||
invalidPathParameter = true;
|
||||
return "";
|
||||
}
|
||||
return encodeURIComponent(serialized);
|
||||
},
|
||||
);
|
||||
if (missingPathParameter) {
|
||||
return { success: false, code: "PATH_PARAMETER_MISSING" };
|
||||
}
|
||||
if (invalidPathParameter) {
|
||||
return { success: false, code: "PATH_PARAMETER_INVALID" };
|
||||
}
|
||||
|
||||
if (
|
||||
parsedSearch === null ||
|
||||
@@ -51,7 +99,12 @@ export function buildRequestTarget(
|
||||
return { success: false, code: "SEARCH_PARAMETER_INVALID" };
|
||||
}
|
||||
|
||||
const url = new URL(pathname, baseUrl);
|
||||
const basePrefix = base.pathname.endsWith("/")
|
||||
? base.pathname
|
||||
: `${base.pathname}/`;
|
||||
const relativePath = pathname.replace(/^\/+/, "");
|
||||
base.pathname = `${basePrefix}${relativePath}`.replace(/\/{2,}/g, "/");
|
||||
const url = base;
|
||||
const search = parsedSearch as Readonly<Record<string, unknown>>;
|
||||
for (const key of Object.keys(search).sort((left, right) =>
|
||||
left.localeCompare(right),
|
||||
@@ -70,5 +123,12 @@ export function buildRequestTarget(
|
||||
url.searchParams.append(key, String(item));
|
||||
}
|
||||
}
|
||||
if (
|
||||
operation.maxEncodedSearchBytes !== undefined &&
|
||||
new TextEncoder().encode(url.search).byteLength >
|
||||
operation.maxEncodedSearchBytes
|
||||
) {
|
||||
return { success: false, code: "SEARCH_PARAMETER_INVALID" };
|
||||
}
|
||||
return { success: true, url };
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
/** @param {string} operationId @param {unknown} payload */
|
||||
export function mapOperationPayload(operationId, payload) {
|
||||
void payload;
|
||||
throw new TypeError(`No boundary mapper registered for ${operationId}`);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import {
|
||||
mappingFailure,
|
||||
type MappingResult,
|
||||
} from "../../contracts/boundary-mapper.ts";
|
||||
|
||||
export function mapOperationPayload(
|
||||
_operationId: string,
|
||||
_payload: unknown,
|
||||
): MappingResult<never> {
|
||||
return mappingFailure("MAPPING_INVARIANT_REJECTED");
|
||||
}
|
||||
@@ -1,27 +1,23 @@
|
||||
const retryKinds = new Set([
|
||||
const retryKinds: ReadonlySet<string> = new Set([
|
||||
"NETWORK_UNREACHABLE",
|
||||
"REQUEST_TIMEOUT",
|
||||
"RATE_LIMITED",
|
||||
"SERVER_FAILURE",
|
||||
]);
|
||||
|
||||
/**
|
||||
* @param {number} retryIndex
|
||||
* @param {() => number} [random]
|
||||
* @param {number} [baseDelayMs]
|
||||
* @param {number} [maxDelayMs]
|
||||
*/
|
||||
export function calculateBackoff(
|
||||
retryIndex,
|
||||
retryIndex: number,
|
||||
random = Math.random,
|
||||
baseDelayMs = 250,
|
||||
maxDelayMs = 2_000,
|
||||
) {
|
||||
): number {
|
||||
return Math.min(maxDelayMs, baseDelayMs * 2 ** retryIndex) * random();
|
||||
}
|
||||
|
||||
/** @param {string | null | undefined} value @param {number} [now] */
|
||||
export function parseRetryAfter(value, now = Date.now()) {
|
||||
export function parseRetryAfter(
|
||||
value: string | null | undefined,
|
||||
now = Date.now(),
|
||||
): number | null {
|
||||
if (!value) return null;
|
||||
|
||||
const seconds = Number(value);
|
||||
@@ -34,13 +30,24 @@ export function parseRetryAfter(value, now = Date.now()) {
|
||||
return Math.max(0, timestamp - now);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ idempotency: "safe" | "keyed" | "none", retry?: "runtime" | "never" }} operation
|
||||
* @param {{ kind: string, retryAfterMs?: number, httpStatus?: number }} failure
|
||||
* @param {number} retryCount
|
||||
* @param {number} [maxRetries]
|
||||
*/
|
||||
export function shouldRetry(operation, failure, retryCount, maxRetries = 2) {
|
||||
export type RetryOperation = Readonly<{
|
||||
idempotency: "safe" | "keyed" | "none";
|
||||
retry?: "runtime" | "never";
|
||||
}>;
|
||||
|
||||
export type RetryFailure = Readonly<{
|
||||
kind: string;
|
||||
retryAfterMs?: number;
|
||||
retryAfter?: string;
|
||||
httpStatus?: number;
|
||||
}>;
|
||||
|
||||
export function shouldRetry(
|
||||
operation: RetryOperation,
|
||||
failure: RetryFailure,
|
||||
retryCount: number,
|
||||
maxRetries = 2,
|
||||
): boolean {
|
||||
if (operation.retry === "never") return false;
|
||||
if (retryCount >= maxRetries) return false;
|
||||
if (!retryKinds.has(failure.kind)) return false;
|
||||
@@ -61,13 +68,12 @@ export function shouldRetry(operation, failure, retryCount, maxRetries = 2) {
|
||||
return operation.idempotency === "safe" || operation.idempotency === "keyed";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ kind: string, retryAfterMs?: number, retryAfter?: string }} failure
|
||||
* @param {number} retryIndex
|
||||
* @param {() => number} [random]
|
||||
* @param {number} [now]
|
||||
*/
|
||||
export function retryDelay(failure, retryIndex, random = Math.random, now = Date.now()) {
|
||||
export function retryDelay(
|
||||
failure: RetryFailure,
|
||||
retryIndex: number,
|
||||
random = Math.random,
|
||||
now = Date.now(),
|
||||
): number {
|
||||
const localBackoff = calculateBackoff(retryIndex, random);
|
||||
if (failure.kind !== "RATE_LIMITED") return localBackoff;
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const metaSchema = z
|
||||
.object({
|
||||
requestId: z.string().min(1),
|
||||
traceId: z.string().min(1),
|
||||
correlationId: z.string().min(1).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const successEnvelopeSchema = z
|
||||
.object({
|
||||
success: z.literal(true),
|
||||
data: z.unknown(),
|
||||
meta: metaSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const failureEnvelopeSchema = z
|
||||
.object({
|
||||
success: z.literal(false),
|
||||
error: z
|
||||
.object({
|
||||
code: z.string().min(1),
|
||||
category: z.string().min(1).optional(),
|
||||
message: z.string().optional(),
|
||||
retryable: z.boolean().optional(),
|
||||
details: z.unknown().optional(),
|
||||
})
|
||||
.strict(),
|
||||
meta: metaSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const responseEnvelopeSchema = z.discriminatedUnion("success", [
|
||||
successEnvelopeSchema,
|
||||
failureEnvelopeSchema,
|
||||
]);
|
||||
|
||||
const payloadSchemas =
|
||||
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({}));
|
||||
|
||||
const requestSchemas =
|
||||
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({}));
|
||||
|
||||
/** @param {unknown} value */
|
||||
export function validateEnvelope(value) {
|
||||
return projectResult(responseEnvelopeSchema.safeParse(value));
|
||||
}
|
||||
|
||||
/** @param {string} schemaId @param {unknown} value */
|
||||
export function validateOperationPayload(schemaId, value) {
|
||||
const schema = payloadSchemas[schemaId];
|
||||
if (!schema) return missingSchema(schemaId);
|
||||
return projectResult(schema.safeParse(value));
|
||||
}
|
||||
|
||||
/** @param {string} schemaId @param {unknown} value */
|
||||
export function validateOperationRequest(schemaId, value) {
|
||||
const schema = requestSchemas[schemaId];
|
||||
if (!schema) return missingSchema(schemaId);
|
||||
return projectResult(schema.safeParse(value));
|
||||
}
|
||||
|
||||
/** @param {string} schemaId */
|
||||
function missingSchema(schemaId) {
|
||||
return {
|
||||
success: /** @type {false} */ (false),
|
||||
issues: [{ path: "", code: "SCHEMA_NOT_REGISTERED", schemaId }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ success: true, data: unknown } |
|
||||
* { success: false, error: { issues: Array<{ path: PropertyKey[], code: string }> } }} result
|
||||
*/
|
||||
function projectResult(result) {
|
||||
if (result.success) {
|
||||
return {
|
||||
success: /** @type {true} */ (true),
|
||||
data: structuredClone(result.data),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: /** @type {false} */ (false),
|
||||
issues: result.error.issues.map((issue) => ({
|
||||
path: issue.path.join("."),
|
||||
code: issue.code,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const metaSchema = z
|
||||
.object({
|
||||
requestId: z.string().regex(/^[A-Za-z0-9._:-]{1,128}$/),
|
||||
traceId: z.string().regex(/^[A-Za-z0-9._:-]{1,128}$/),
|
||||
correlationId: z.string().regex(/^[A-Za-z0-9._:-]{1,128}$/).optional(),
|
||||
})
|
||||
.strip();
|
||||
|
||||
export const successEnvelopeSchema = z
|
||||
.object({
|
||||
success: z.literal(true),
|
||||
data: z.unknown(),
|
||||
meta: metaSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const failureEnvelopeSchema = z
|
||||
.object({
|
||||
success: z.literal(false),
|
||||
error: z
|
||||
.object({
|
||||
code: z.string().regex(/^[A-Z0-9_]{1,64}$/),
|
||||
category: z.string().min(1).max(64).optional(),
|
||||
message: z.string().max(1_024).optional(),
|
||||
retryable: z.boolean().optional(),
|
||||
details: z.unknown().optional(),
|
||||
})
|
||||
.strict(),
|
||||
meta: metaSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const responseEnvelopeSchema = z.discriminatedUnion("success", [
|
||||
successEnvelopeSchema,
|
||||
failureEnvelopeSchema,
|
||||
]);
|
||||
|
||||
const payloadSchemas: Readonly<Record<string, z.ZodType<unknown>>> =
|
||||
Object.freeze({});
|
||||
|
||||
const requestSchemas: Readonly<Record<string, z.ZodType<unknown>>> =
|
||||
Object.freeze({});
|
||||
|
||||
export type SchemaIssue = Readonly<{
|
||||
path: string;
|
||||
code: string;
|
||||
schemaId?: string;
|
||||
}>;
|
||||
|
||||
export type SchemaValidationResult =
|
||||
| Readonly<{ success: true; data: unknown }>
|
||||
| Readonly<{ success: false; issues: readonly SchemaIssue[] }>;
|
||||
|
||||
export function validateEnvelope(value: unknown): SchemaValidationResult {
|
||||
return projectResult(responseEnvelopeSchema.safeParse(value));
|
||||
}
|
||||
|
||||
export function validateOperationPayload(
|
||||
schemaId: string,
|
||||
value: unknown,
|
||||
): SchemaValidationResult {
|
||||
const schema = payloadSchemas[schemaId];
|
||||
if (!schema) return missingSchema(schemaId);
|
||||
return projectResult(schema.safeParse(value));
|
||||
}
|
||||
|
||||
export function validateOperationRequest(
|
||||
schemaId: string,
|
||||
value: unknown,
|
||||
): SchemaValidationResult {
|
||||
const schema = requestSchemas[schemaId];
|
||||
if (!schema) return missingSchema(schemaId);
|
||||
return projectResult(schema.safeParse(value));
|
||||
}
|
||||
|
||||
function missingSchema(schemaId: string): SchemaValidationResult {
|
||||
return {
|
||||
success: false,
|
||||
issues: [{ path: "", code: "SCHEMA_NOT_REGISTERED", schemaId }],
|
||||
};
|
||||
}
|
||||
|
||||
function projectResult(
|
||||
result:
|
||||
| Readonly<{ success: true; data: unknown }>
|
||||
| Readonly<{
|
||||
success: false;
|
||||
error: Readonly<{
|
||||
issues: readonly Readonly<{
|
||||
path: readonly PropertyKey[];
|
||||
code: string;
|
||||
}>[];
|
||||
}>;
|
||||
}>,
|
||||
): SchemaValidationResult {
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: structuredClone(result.data),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
issues: result.error.issues.map((issue) => ({
|
||||
path: issue.path.join("."),
|
||||
code: issue.code,
|
||||
})),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user