feat: execute HTTP and query runtime contracts

This commit is contained in:
donghyeon-ka
2026-07-26 14:05:12 +09:00
parent 8aaaa033c0
commit ad55e21a3d
29 changed files with 1326 additions and 185 deletions
+109 -32
View File
@@ -12,6 +12,7 @@ import {
validateOperationPayload,
validateOperationRequest,
} from "./schema-registry.js";
import { buildRequestTarget } from "./request-builder.js";
const noAuthSession =
/** @type {import("../../application/ports/auth-session-port.js").AuthSessionPort} */ ({
@@ -22,6 +23,14 @@ const noAuthSession =
});
/** @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> } |
@@ -38,7 +47,11 @@ const noAuthSession =
* validatePayload?: (schemaId: string, value: unknown) =>
* { success: true, data: unknown } | { success: false },
* mapPayload?: (operationId: string, payload: unknown) => unknown,
* idempotencyKeyFactory?: () => string
* idempotencyKeyFactory?: () => string,
* timeoutMs?: number,
* maxRetryAttempts?: number,
* scheduler?: Scheduler,
* getOperation?: typeof getApiOperation
* }} dependencies
*/
export function createHttpClient(dependencies) {
@@ -51,19 +64,46 @@ export function createHttpClient(dependencies) {
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 scheduler =
dependencies.scheduler ??
/** @type {Scheduler} */ ({
setTimeout: (callback, milliseconds) =>
globalThis.setTimeout(callback, milliseconds),
clearTimeout: (handle) =>
globalThis.clearTimeout(
/** @type {ReturnType<typeof setTimeout>} */ (handle),
),
});
/**
* @param {string} operationId
* @param {string | OperationRequestInput} request
* @param {{
* body?: unknown,
* routeId?: string,
* signal?: AbortSignal,
* idempotencyKey?: string
* }} [input]
* @returns {Promise<HttpResult>}
*/
async function execute(operationId, input = {}) {
const operation = getApiOperation(operationId);
* body?: unknown,
* routeId?: string,
* pathParams?: Record<string, string | number>,
* searchParams?: unknown,
* signal?: AbortSignal,
* idempotencyKey?: 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,
}
: request;
const operation = selectOperation(input.operationId);
const logicalIdempotencyKey =
operation.idempotency === "keyed"
? input.idempotencyKey ?? idempotencyKeyFactory()
@@ -105,7 +145,14 @@ export function createHttpClient(dependencies) {
return outcome;
}
if (!shouldRetry(operation, outcome.error, retryCount)) {
if (
!shouldRetry(
operation,
outcome.error,
retryCount,
maxRetryAttempts,
)
) {
return outcome;
}
@@ -117,7 +164,7 @@ export function createHttpClient(dependencies) {
} catch {
return {
ok: false,
error: failure("REQUEST_ABORTED", operationId, retryCount, {
error: failure("REQUEST_ABORTED", input.operationId, retryCount, {
code: "REQUEST_ABORTED",
}),
};
@@ -128,7 +175,7 @@ export function createHttpClient(dependencies) {
/**
* @param {{
* operation: ReturnType<typeof getApiOperation>,
* input: { body?: unknown, routeId?: string, signal?: AbortSignal },
* input: OperationRequestInput,
* attempt: number,
* idempotencyKey?: string
* }} context
@@ -136,23 +183,19 @@ export function createHttpClient(dependencies) {
*/
async function performAttempt(context) {
const { operation, input, attempt, idempotencyKey } = context;
const controller = new AbortController();
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
controller.abort("timeout");
}, operation.timeoutMs);
const onExternalAbort = () => controller.abort(input.signal?.reason);
input.signal?.addEventListener("abort", onExternalAbort, { once: true });
const headers = new Headers({ Accept: "application/json" });
if (input.body !== undefined) headers.set("Content-Type", "application/json");
if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey);
if (input.body !== undefined) {
/** @type {unknown} */
let parsedSearch = {};
let parsedBody;
const requestValue =
operation.requestSource === "search"
? input.searchParams ?? {}
: operation.requestSource === "body"
? input.body
: {};
if (operation.requestSource !== "none") {
const requestValidation = validateOperationRequest(
operation.requestSchema,
input.body,
requestValue,
);
if (!requestValidation.success) {
return {
@@ -162,12 +205,46 @@ export function createHttpClient(dependencies) {
}),
};
}
if (operation.requestSource === "search") {
parsedSearch = requestValidation.data;
} else {
parsedBody = requestValidation.data;
}
}
let request = new Request(new URL(operation.path, dependencies.baseUrl), {
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: input.body === undefined ? undefined : JSON.stringify(input.body),
body: parsedBody === undefined ? undefined : JSON.stringify(parsedBody),
signal: controller.signal,
});
@@ -237,7 +314,7 @@ export function createHttpClient(dependencies) {
}),
};
} finally {
clearTimeout(timeout);
scheduler.clearTimeout(timeout);
input.signal?.removeEventListener("abort", onExternalAbort);
}
}
+73
View File
@@ -0,0 +1,73 @@
import type { ApiOperation } from "../../contracts/api-operations.js";
export type OperationRequestInput = Readonly<{
operationId: string;
routeId: string;
pathParams?: Readonly<Record<string, string | number>>;
searchParams?: unknown;
body?: unknown;
signal?: AbortSignal;
idempotencyKey?: string;
}>;
export type RequestTargetResult =
| Readonly<{ success: true; url: URL }>
| Readonly<{
success: false;
code: "PATH_PARAMETER_MISSING" | "SEARCH_PARAMETER_INVALID";
}>;
const pathParameterPattern = /:([A-Za-z][A-Za-z0-9_]*)|\{([A-Za-z][A-Za-z0-9_]*)\}/g;
export function buildRequestTarget(
baseUrl: string,
operation: ApiOperation,
pathParams: Readonly<Record<string, string | number>> = {},
parsedSearch: unknown = {},
): RequestTargetResult {
let missingPathParameter = false;
const pathname = operation.path.replace(
pathParameterPattern,
(_token, colonName: string | undefined, braceName: string | undefined) => {
const name = colonName ?? braceName ?? "";
const value = pathParams[name];
if (value === undefined) {
missingPathParameter = true;
return "";
}
return encodeURIComponent(String(value));
},
);
if (missingPathParameter) {
return { success: false, code: "PATH_PARAMETER_MISSING" };
}
if (
parsedSearch === null ||
typeof parsedSearch !== "object" ||
Array.isArray(parsedSearch)
) {
return { success: false, code: "SEARCH_PARAMETER_INVALID" };
}
const url = new URL(pathname, baseUrl);
const search = parsedSearch as Readonly<Record<string, unknown>>;
for (const key of Object.keys(search).sort((left, right) =>
left.localeCompare(right),
)) {
const value = search[key];
if (value === undefined || value === null) continue;
const values = Array.isArray(value) ? value : [value];
for (const item of values) {
if (
typeof item !== "string" &&
typeof item !== "number" &&
typeof item !== "boolean"
) {
return { success: false, code: "SEARCH_PARAMETER_INVALID" };
}
url.searchParams.append(key, String(item));
}
}
return { success: true, url };
}
+2 -1
View File
@@ -35,12 +35,13 @@ export function parseRetryAfter(value, now = Date.now()) {
}
/**
* @param {{ idempotency: "safe" | "keyed" | "none" }} operation
* @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) {
if (operation.retry === "never") return false;
if (retryCount >= maxRetries) return false;
if (!retryKinds.has(failure.kind)) return false;
if (
+1
View File
@@ -57,6 +57,7 @@ const requestSchemas =
.object({
cursor: z.string().optional(),
limit: z.int().min(1).max(100).default(20),
tags: z.array(z.string().trim().min(1)).optional(),
})
.strict(),
CreateSampleResourceCommand: z
+5 -1
View File
@@ -8,9 +8,13 @@ export const systemClock = Object.freeze({
return;
}
const timer = setTimeout(resolve, milliseconds);
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, milliseconds);
const onAbort = () => {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
reject(signal?.reason);
};
signal?.addEventListener("abort", onAbort, { once: true });