diff --git a/packages/client/src/core/normalize-error.ts b/packages/client/src/core/normalize-error.ts index 61aec65..a5beb13 100644 --- a/packages/client/src/core/normalize-error.ts +++ b/packages/client/src/core/normalize-error.ts @@ -1,4 +1,4 @@ -import { HttpError } from '../errors/http-error'; +import { DfsyncError } from '../errors/base-error'; import { NetworkError } from '../errors/network-error'; import { TimeoutError } from '../errors/timeout-error'; import { RequestAbortedError } from '../errors/request-aborted-error'; @@ -9,7 +9,7 @@ export function normalizeError( timeout: number, abortReason?: RequestAbortReason, ): Error { - if (error instanceof HttpError) { + if (error instanceof DfsyncError) { return error; } diff --git a/packages/client/src/core/request.ts b/packages/client/src/core/request.ts index bc60020..6efae5c 100644 --- a/packages/client/src/core/request.ts +++ b/packages/client/src/core/request.ts @@ -1,5 +1,6 @@ import { HttpError } from '../errors/http-error'; import { NetworkError } from '../errors/network-error'; +import { ValidationError } from '../errors/validation-error'; import type { HeadersMap } from '../types/common'; import type { ClientConfig } from '../types/config'; import type { RequestConfig } from '../types/request'; @@ -105,6 +106,16 @@ export async function request( if (!response.ok) { throw new HttpError(response, data); } + + const validateResponse = execution.request.validateResponse ?? clientConfig.validateResponse; + + if (validateResponse) { + const validationResult = await validateResponse(data); + + if (validationResult === false) { + throw new ValidationError(response, data); + } + } } catch (rawError) { const error = normalizeError(rawError, timeout, requestController.getAbortReason()); lastError = error; diff --git a/packages/client/src/errors/validation-error.ts b/packages/client/src/errors/validation-error.ts new file mode 100644 index 0000000..517c88a --- /dev/null +++ b/packages/client/src/errors/validation-error.ts @@ -0,0 +1,14 @@ +import { DfsyncError } from './base-error'; + +export class ValidationError extends DfsyncError { + public readonly data: unknown; + public readonly response: Response; + + constructor(response: Response, data: unknown) { + super('Response validation failed', 'VALIDATION_ERROR'); + + this.name = 'ValidationError'; + this.response = response; + this.data = data; + } +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 1e71b92..e77b1d1 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -4,11 +4,18 @@ export { DfsyncError } from './errors/base-error'; export { HttpError } from './errors/http-error'; export { NetworkError } from './errors/network-error'; export { TimeoutError } from './errors/timeout-error'; +export { ValidationError } from './errors/validation-error'; export { RequestAbortedError } from './errors/request-aborted-error'; export type { AuthConfig } from './types/auth'; export type { Client } from './types/client'; -export type { ClientConfig, RetryConfig, RetryCondition, RetryBackoff } from './types/config'; +export type { + ClientConfig, + RetryConfig, + RetryCondition, + RetryBackoff, + ResponseValidator, +} from './types/config'; export type { AfterResponseContext, BeforeRequestContext, diff --git a/packages/client/src/types/config.ts b/packages/client/src/types/config.ts index 812489a..b74af5d 100644 --- a/packages/client/src/types/config.ts +++ b/packages/client/src/types/config.ts @@ -3,6 +3,10 @@ import type { AuthConfig } from './auth'; import type { HooksConfig } from './hooks'; import type { RequestMethod } from './request'; +export type ResponseValidator = ( + data: TData, +) => boolean | void | Promise; + export type RetryCondition = 'network-error' | '5xx' | '429'; export type RetryBackoff = 'fixed' | 'exponential'; @@ -22,4 +26,5 @@ export type ClientConfig = { hooks?: HooksConfig; retry?: RetryConfig; fetch?: typeof globalThis.fetch; + validateResponse?: ResponseValidator; }; diff --git a/packages/client/src/types/request.ts b/packages/client/src/types/request.ts index 60d175a..9b709ea 100644 --- a/packages/client/src/types/request.ts +++ b/packages/client/src/types/request.ts @@ -1,5 +1,5 @@ import type { HeadersMap, QueryParams } from './common'; -import type { RetryConfig } from './config'; +import type { RetryConfig, ResponseValidator } from './config'; export type RequestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; @@ -13,6 +13,7 @@ export type RequestConfig = { retry?: RetryConfig; signal?: AbortSignal; requestId?: string; + validateResponse?: ResponseValidator; }; export type RequestOptionsWithoutBody = Omit; diff --git a/packages/client/tests/integration/response-validation.test.ts b/packages/client/tests/integration/response-validation.test.ts new file mode 100644 index 0000000..e0b2fb5 --- /dev/null +++ b/packages/client/tests/integration/response-validation.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createClient, ValidationError } from '../../src'; + +describe('response validation', () => { + it('returns data when client-level validation passes', async () => { + 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, + validateResponse(data) { + return typeof data === 'object' && data !== null && 'id' in data; + }, + }); + + await expect(client.get('/users/1')).resolves.toEqual({ id: 'user-1' }); + }); + + it('throws ValidationError when client-level validation fails', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ name: 'Roman' }), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: fetchMock, + validateResponse(data) { + return typeof data === 'object' && data !== null && 'id' in data; + }, + }); + + await expect(client.get('/users/1')).rejects.toBeInstanceOf(ValidationError); + }); + + it('uses request-level validation over client-level validation', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ name: 'Roman' }), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: fetchMock, + validateResponse() { + return false; + }, + }); + + await expect( + client.get('/users/1', { + validateResponse(data) { + return typeof data === 'object' && data !== null && 'name' in data; + }, + }), + ).resolves.toEqual({ name: 'Roman' }); + }); + + it('supports async validation', async () => { + 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, + async validateResponse(data) { + return typeof data === 'object' && data !== null && 'id' in data; + }, + }); + + await expect(client.get('/users/1')).resolves.toEqual({ id: 'user-1' }); + }); +}); diff --git a/packages/client/tests/unit/validation-error.test.ts b/packages/client/tests/unit/validation-error.test.ts new file mode 100644 index 0000000..cf58db0 --- /dev/null +++ b/packages/client/tests/unit/validation-error.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { ValidationError } from '../../src/errors/validation-error'; + +describe('ValidationError', () => { + it('stores response and data', () => { + const data = { message: 'invalid payload' }; + const response = new Response(JSON.stringify(data), { + status: 200, + statusText: 'OK', + }); + + const error = new ValidationError(response, data); + + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('ValidationError'); + expect(error.code).toBe('VALIDATION_ERROR'); + expect(error.message).toBe('Response validation failed'); + expect(error.response).toBe(response); + expect(error.data).toBe(data); + }); +});