Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/client/src/core/execution-context.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -11,6 +12,7 @@ export type ExecutionContext = {
startedAt: number;
endedAt?: number;
durationMs?: number;
validation?: ResponseValidationResult;
};

type CreateExecutionContextParams = {
Expand Down
1 change: 1 addition & 0 deletions packages/client/src/core/hook-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export function createAfterResponseContext<T>(
...createLifecycleContextBase(execution),
response,
data,
...(execution.validation !== undefined ? { validation: execution.validation } : {}),
};
}

Expand Down
5 changes: 5 additions & 0 deletions packages/client/src/core/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ export async function request<T>(
if (validateResponse) {
const validationResult = await validateResponse(data);

execution.validation = {
enabled: true,
passed: validationResult !== false,
};

if (validationResult === false) {
throw new ValidationError(response, data);
}
Expand Down
6 changes: 6 additions & 0 deletions packages/client/src/types/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -20,6 +25,7 @@ export type BeforeRequestContext = LifecycleContextBase;
export type AfterResponseContext<T = unknown> = LifecycleContextBase & {
response: Response;
data: T;
validation?: ResponseValidationResult;
};

export type ErrorContext = LifecycleContextBase & {
Expand Down
64 changes: 64 additions & 0 deletions packages/client/tests/integration/response-validation.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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');
});
});
Loading