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
4 changes: 2 additions & 2 deletions packages/client/src/core/normalize-error.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -9,7 +9,7 @@ export function normalizeError(
timeout: number,
abortReason?: RequestAbortReason,
): Error {
if (error instanceof HttpError) {
if (error instanceof DfsyncError) {
return error;
}

Expand Down
11 changes: 11 additions & 0 deletions packages/client/src/core/request.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -105,6 +106,16 @@ export async function request<T>(
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;
Expand Down
14 changes: 14 additions & 0 deletions packages/client/src/errors/validation-error.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
9 changes: 8 additions & 1 deletion packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions packages/client/src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import type { AuthConfig } from './auth';
import type { HooksConfig } from './hooks';
import type { RequestMethod } from './request';

export type ResponseValidator<TData = unknown> = (
data: TData,
) => boolean | void | Promise<boolean | void>;

export type RetryCondition = 'network-error' | '5xx' | '429';
export type RetryBackoff = 'fixed' | 'exponential';

Expand All @@ -22,4 +26,5 @@ export type ClientConfig = {
hooks?: HooksConfig;
retry?: RetryConfig;
fetch?: typeof globalThis.fetch;
validateResponse?: ResponseValidator;
};
3 changes: 2 additions & 1 deletion packages/client/src/types/request.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -13,6 +13,7 @@ export type RequestConfig = {
retry?: RetryConfig;
signal?: AbortSignal;
requestId?: string;
validateResponse?: ResponseValidator;
};

export type RequestOptionsWithoutBody = Omit<RequestConfig, 'method' | 'path' | 'body'>;
Expand Down
95 changes: 95 additions & 0 deletions packages/client/tests/integration/response-validation.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
22 changes: 22 additions & 0 deletions packages/client/tests/unit/validation-error.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading