feat: validate HTTP envelopes and payload schemas
This commit is contained in:
+33
-17
@@ -1,6 +1,11 @@
|
||||
import { systemClock } from "../../application/ports/clock-port.js";
|
||||
import { getApiOperation } from "../../contracts/api-operations.js";
|
||||
import { retryDelay, shouldRetry } from "./retry-policy.js";
|
||||
import {
|
||||
validateEnvelope,
|
||||
validateOperationPayload,
|
||||
validateOperationRequest,
|
||||
} from "./schema-registry.js";
|
||||
|
||||
const noAuthSession =
|
||||
/** @type {import("../../application/ports/auth-session-port.js").AuthSessionPort} */ ({
|
||||
@@ -59,8 +64,7 @@ export function createHttpClient(dependencies) {
|
||||
const clock = dependencies.clock ?? systemClock;
|
||||
const random = dependencies.random ?? Math.random;
|
||||
const validatePayload =
|
||||
dependencies.validatePayload ??
|
||||
((_schemaId, value) => ({ success: /** @type {true} */ (true), data: value }));
|
||||
dependencies.validatePayload ?? validateOperationPayload;
|
||||
const idempotencyKeyFactory =
|
||||
dependencies.idempotencyKeyFactory ?? (() => crypto.randomUUID());
|
||||
|
||||
@@ -155,6 +159,21 @@ export function createHttpClient(dependencies) {
|
||||
if (input.body !== undefined) headers.set("Content-Type", "application/json");
|
||||
if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey);
|
||||
|
||||
if (input.body !== undefined) {
|
||||
const requestValidation = validateOperationRequest(
|
||||
operation.requestSchema,
|
||||
input.body,
|
||||
);
|
||||
if (!requestValidation.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("VALIDATION_REJECTED", operation.operationId, attempt, {
|
||||
code: "REQUEST_SCHEMA_INVALID",
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let request = new Request(new URL(operation.path, dependencies.baseUrl), {
|
||||
method: operation.method,
|
||||
headers,
|
||||
@@ -263,27 +282,24 @@ async function parseResponse(response, operation, attempt, validatePayload) {
|
||||
};
|
||||
}
|
||||
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
const envelopeValidation = validateEnvelope(envelope);
|
||||
if (!envelopeValidation.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("ENVELOPE_MISMATCH", operation.operationId, attempt, {
|
||||
code: "ENVELOPE_MISMATCH",
|
||||
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>} */ (envelope);
|
||||
if (typeof envelopeRecord.success !== "boolean") {
|
||||
return {
|
||||
ok: false,
|
||||
error: failure("ENVELOPE_MISMATCH", operation.operationId, attempt, {
|
||||
code: "ENVELOPE_MISMATCH",
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
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 sampleResourceSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
createdAt: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const payloadSchemas =
|
||||
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({
|
||||
SampleResourceListPayload: z.array(sampleResourceSchema),
|
||||
SampleResourcePayload: sampleResourceSchema,
|
||||
}));
|
||||
|
||||
const requestSchemas =
|
||||
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({
|
||||
SampleResourceListQuery: z
|
||||
.object({
|
||||
cursor: z.string().optional(),
|
||||
limit: z.int().min(1).max(100).default(20),
|
||||
})
|
||||
.strict(),
|
||||
CreateSampleResourceCommand: z
|
||||
.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
})
|
||||
.strict(),
|
||||
}));
|
||||
|
||||
/** @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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user