From df793435d981ac60ca0dcf7fc515125bc44471ca Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Mon, 27 Apr 2026 13:09:18 +0200 Subject: [PATCH] feat(client): expose validation result in hooks --- packages/client/src/core/execution-context.ts | 2 + packages/client/src/core/hook-context.ts | 1 + packages/client/src/core/request.ts | 5 ++ packages/client/src/types/hooks.ts | 6 ++ .../integration/response-validation.test.ts | 64 +++++++++++++++++++ 5 files changed, 78 insertions(+) diff --git a/packages/client/src/core/execution-context.ts b/packages/client/src/core/execution-context.ts index a1abf41..752425a 100644 --- a/packages/client/src/core/execution-context.ts +++ b/packages/client/src/core/execution-context.ts @@ -1,4 +1,5 @@ import type { HeadersMap } from '../types/common'; +import type { ResponseValidationResult } from '../types/hooks'; import type { RequestConfig } from '../types/request'; export type ExecutionContext = { @@ -11,6 +12,7 @@ export type ExecutionContext = { startedAt: number; endedAt?: number; durationMs?: number; + validation?: ResponseValidationResult; }; type CreateExecutionContextParams = { diff --git a/packages/client/src/core/hook-context.ts b/packages/client/src/core/hook-context.ts index de50c55..5422cbb 100644 --- a/packages/client/src/core/hook-context.ts +++ b/packages/client/src/core/hook-context.ts @@ -38,6 +38,7 @@ export function createAfterResponseContext( ...createLifecycleContextBase(execution), response, data, + ...(execution.validation !== undefined ? { validation: execution.validation } : {}), }; } diff --git a/packages/client/src/core/request.ts b/packages/client/src/core/request.ts index 6efae5c..5ad74a0 100644 --- a/packages/client/src/core/request.ts +++ b/packages/client/src/core/request.ts @@ -112,6 +112,11 @@ export async function request( if (validateResponse) { const validationResult = await validateResponse(data); + execution.validation = { + enabled: true, + passed: validationResult !== false, + }; + if (validationResult === false) { throw new ValidationError(response, data); } diff --git a/packages/client/src/types/hooks.ts b/packages/client/src/types/hooks.ts index fc4603f..dfe2e37 100644 --- a/packages/client/src/types/hooks.ts +++ b/packages/client/src/types/hooks.ts @@ -2,6 +2,11 @@ import type { HeadersMap } from './common'; import type { RequestConfig } from './request'; import type { RetryCondition } from './config'; +export type ResponseValidationResult = { + enabled: boolean; + passed: boolean; +}; + type LifecycleContextBase = { request: RequestConfig; url: URL; @@ -20,6 +25,7 @@ export type BeforeRequestContext = LifecycleContextBase; export type AfterResponseContext = LifecycleContextBase & { response: Response; data: T; + validation?: ResponseValidationResult; }; export type ErrorContext = LifecycleContextBase & { diff --git a/packages/client/tests/integration/response-validation.test.ts b/packages/client/tests/integration/response-validation.test.ts index 9a660cb..7dd142f 100644 --- a/packages/client/tests/integration/response-validation.test.ts +++ b/packages/client/tests/integration/response-validation.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { createClient, ValidationError } from '../../src'; +import { getFirstMockCall } from '../testUtils'; describe('response validation', () => { it('returns data when client-level validation passes', async () => { @@ -120,4 +121,67 @@ describe('response validation', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + + it('exposes validation result in afterResponse hook when validation passes', async () => { + const afterResponse = vi.fn(); + + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ id: 'user-1' }), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: fetchMock, + hooks: { + afterResponse, + }, + validateResponse(data) { + return typeof data === 'object' && data !== null && 'id' in data; + }, + }); + + await client.get('/users/1'); + + expect(afterResponse).toHaveBeenCalledTimes(1); + expect(afterResponse).toHaveBeenCalledWith( + expect.objectContaining({ + validation: { + enabled: true, + passed: true, + }, + }), + ); + }); + + it('does not expose validation result when validation is not configured', async () => { + const afterResponse = vi.fn(); + + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ id: 'user-1' }), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: fetchMock, + hooks: { + afterResponse, + }, + }); + + await client.get('/users/1'); + + expect(afterResponse).toHaveBeenCalledTimes(1); + const ctx = getFirstMockCall(afterResponse); + expect(ctx).not.toHaveProperty('validation'); + }); });