From bf813f09ab165138d7895a2abd6faaf7b1b5aa23 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 20:51:27 +0900 Subject: [PATCH] feat: validate HTTP envelopes and payload schemas --- src/adapters/http/client.js | 50 ++++++---- src/adapters/http/schema-registry.js | 115 +++++++++++++++++++++++ tests/integration/http-client.test.js | 31 ++++++ tests/runtime-schema/http-schema.test.js | 53 +++++++++++ 4 files changed, 232 insertions(+), 17 deletions(-) create mode 100644 src/adapters/http/schema-registry.js create mode 100644 tests/runtime-schema/http-schema.test.js diff --git a/src/adapters/http/client.js b/src/adapters/http/client.js index a7a0e63..3831cb5 100644 --- a/src/adapters/http/client.js +++ b/src/adapters/http/client.js @@ -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} */ (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} */ (envelopeValidation.data); if (response.ok && envelopeRecord.success === true && "data" in envelopeRecord) { const payload = validatePayload(operation.responseSchema, envelopeRecord.data); if (!payload.success) { diff --git a/src/adapters/http/schema-registry.js b/src/adapters/http/schema-registry.js new file mode 100644 index 0000000..bc2ca78 --- /dev/null +++ b/src/adapters/http/schema-registry.js @@ -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>} */ (Object.freeze({ + SampleResourceListPayload: z.array(sampleResourceSchema), + SampleResourcePayload: sampleResourceSchema, +})); + +const requestSchemas = + /** @type {Readonly>} */ (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, + })), + }; +} diff --git a/tests/integration/http-client.test.js b/tests/integration/http-client.test.js index 28c1dd0..b3633c4 100644 --- a/tests/integration/http-client.test.js +++ b/tests/integration/http-client.test.js @@ -68,4 +68,35 @@ describe("shared HTTP client", () => { }); expect(JSON.stringify(result)).not.toContain("raw body"); }); + + it("classifies malformed JSON and invalid payloads at the boundary", async () => { + server.use( + http.get( + "https://api.test/api/sample/resources", + () => + new HttpResponse("{", { + headers: { "Content-Type": "application/json" }, + }), + ), + ); + const client = createHttpClient({ baseUrl: "https://api.test", clock }); + await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({ + ok: false, + error: { kind: "MALFORMED_JSON" }, + }); + + server.use( + http.get("https://api.test/api/sample/resources", () => + HttpResponse.json({ + success: true, + data: [{ id: "resource-1", name: 42 }], + meta: { requestId: "request-1", traceId: "trace-1" }, + }), + ), + ); + await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({ + ok: false, + error: { kind: "SCHEMA_MISMATCH" }, + }); + }); }); diff --git a/tests/runtime-schema/http-schema.test.js b/tests/runtime-schema/http-schema.test.js new file mode 100644 index 0000000..d9b4b1d --- /dev/null +++ b/tests/runtime-schema/http-schema.test.js @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { + validateEnvelope, + validateOperationPayload, + validateOperationRequest, +} from "../../src/adapters/http/schema-registry.js"; + +describe("HTTP runtime schema boundary", () => { + it("rejects an invalid top-level envelope", () => { + expect(validateEnvelope({ success: true }).success).toBe(false); + }); + + it("rejects an invalid operation payload with safe issue metadata", () => { + const result = validateOperationPayload("SampleResourceListPayload", [ + { id: "resource-1", name: 42 }, + ]); + + expect(result).toMatchObject({ + success: false, + issues: [{ path: "0.name" }], + }); + expect(JSON.stringify(result)).not.toContain("resource-1"); + }); + + it("returns a deep-cloned additive-tolerant payload", () => { + const source = [{ id: "resource-1", name: "Example", additive: "accepted" }]; + const result = validateOperationPayload("SampleResourceListPayload", source); + + expect(result).toMatchObject({ + success: true, + data: [{ id: "resource-1", additive: "accepted" }], + }); + expect(result.data).not.toBe(source); + }); + + it("validates outbound commands before transport", () => { + expect( + validateOperationRequest("CreateSampleResourceCommand", { name: "" }).success, + ).toBe(false); + expect( + validateOperationRequest("CreateSampleResourceCommand", { name: "Example" }) + .success, + ).toBe(true); + }); + + it("fails closed for an unregistered schema", () => { + expect(validateOperationPayload("UnknownPayload", {})).toMatchObject({ + success: false, + issues: [{ code: "SCHEMA_NOT_REGISTERED" }], + }); + }); +});