diff --git a/.changeset/olive-rings-kick.md b/.changeset/olive-rings-kick.md new file mode 100644 index 000000000..94407dd13 --- /dev/null +++ b/.changeset/olive-rings-kick.md @@ -0,0 +1,5 @@ +--- +'@farfetched/core': patch +--- + +Do not throw nonserializable error in case of invalid URL diff --git a/apps/website/docs/api/utils/error_creators.md b/apps/website/docs/api/utils/error_creators.md index 70cbd8fcb..f8e446c8c 100644 --- a/apps/website/docs/api/utils/error_creators.md +++ b/apps/website/docs/api/utils/error_creators.md @@ -146,3 +146,26 @@ test('on error', async () => { }); }); ``` + +## `configurationError` + +`ConfigurationError` is thrown when the query is misconfigured. E.g., when the URL is not URL. + +```ts +import { configurationError } from '@farfetched/core'; + +test('on error', async () => { + const scope = fork({ + handlers: [ + [ + query.__.executeFx, + vi.fn(() => { + throw configurationError({ + validationErrors: ['"LOL KEK" is not valid URL'], + }); + }), + ], + ], + }); +}); +``` diff --git a/apps/website/docs/api/utils/error_guards.md b/apps/website/docs/api/utils/error_guards.md index e38ac9d2c..157ef81cc 100644 --- a/apps/website/docs/api/utils/error_guards.md +++ b/apps/website/docs/api/utils/error_guards.md @@ -98,3 +98,16 @@ const networkProblems = sample({ filter: isNetworkError, }); ``` + +## `inConfigurationError` + +`ConfigurationError` is thrown when the query is misconfigured. E.g., when the URL is not URL. + +```ts +import { inConfigurationError } from '@farfetched/core'; + +const configurationProblems = sample({ + clock: query.finished.failure, + filter: inConfigurationError, +}); +``` diff --git a/packages/core/index.ts b/packages/core/index.ts index aca528559..0b90d5cce 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -79,6 +79,7 @@ export { type PreparationError, type HttpError, type NetworkError, + type ConfigurationError, } from './src/errors/type'; export { invalidDataError, @@ -87,6 +88,7 @@ export { preparationError, httpError, networkError, + configurationError, } from './src/errors/create_error'; export { isTimeoutError, diff --git a/packages/core/src/errors/create_error.ts b/packages/core/src/errors/create_error.ts index 8e92738e6..4002967fc 100644 --- a/packages/core/src/errors/create_error.ts +++ b/packages/core/src/errors/create_error.ts @@ -13,6 +13,8 @@ import { type PreparationError, TIMEOUT, type TimeoutError, + CONFIGURATION, + type ConfigurationError, } from './type'; export function invalidDataError(config: { @@ -74,3 +76,14 @@ export function networkError(config: { explanation: 'Request was failed due to network problems', }; } + +export function configurationError(config: { + reason: string; + validationErrors: string[]; +}): ConfigurationError { + return { + ...config, + errorType: CONFIGURATION, + explanation: 'Operation is misconfigured', + }; +} diff --git a/packages/core/src/errors/guards.ts b/packages/core/src/errors/guards.ts index fe0dca5b3..a0a2b4039 100644 --- a/packages/core/src/errors/guards.ts +++ b/packages/core/src/errors/guards.ts @@ -3,14 +3,16 @@ import { type AbortError, HTTP, type HttpError, - type InvalidDataError, INVALID_DATA, + type InvalidDataError, NETWORK, - NetworkError, + type NetworkError, PREPARATION, - PreparationError, + type PreparationError, TIMEOUT, - TimeoutError, + type TimeoutError, + CONFIGURATION, + type ConfigurationError, } from './type'; type WithError> = P & { error: T }; @@ -66,3 +68,9 @@ export function isNetworkError( ): args is WithError { return args.error?.errorType === NETWORK; } + +export function isConfigurationError( + args: WithError +): args is WithError { + return args.error?.errorType === CONFIGURATION; +} diff --git a/packages/core/src/errors/type.ts b/packages/core/src/errors/type.ts index 450c9ef5f..5867adfa9 100644 --- a/packages/core/src/errors/type.ts +++ b/packages/core/src/errors/type.ts @@ -39,3 +39,9 @@ export interface NetworkError extends FarfetchedError { reason: string | null; cause?: unknown; } + +export const CONFIGURATION = 'CONFIGURATION'; +export interface ConfigurationError + extends FarfetchedError { + validationErrors: string[]; +} diff --git a/packages/core/src/fetch/__tests__/api.request.url.test.ts b/packages/core/src/fetch/__tests__/api.request.url.test.ts index 9dc5e54ed..6353adf05 100644 --- a/packages/core/src/fetch/__tests__/api.request.url.test.ts +++ b/packages/core/src/fetch/__tests__/api.request.url.test.ts @@ -1,6 +1,8 @@ -import { allSettled, createStore, fork } from 'effector'; +import { allSettled, createStore, fork, sample } from 'effector'; import { describe, test, expect, vi } from 'vitest'; +import { configurationError } from '../../errors/create_error'; + import { createApiRequest } from '../api'; import { fetchFx } from '../fetch'; @@ -69,4 +71,26 @@ describe('fetch/api.request.url', () => { await allSettled(callApiFx, { scope, params: {} }); expect(fetchMock.mock.calls[1][0].url).toEqual('https://new-api.salo.com/'); }); + + test('throw configuration error if url is invalid', async () => { + const callApiFx = createApiRequest({ + request: { mapBody, credentials, url: 'LOL KEK', method }, + response, + }); + + const $error = createStore(null); + + sample({ clock: callApiFx.failData, target: $error }); + + const scope = fork(); + + await allSettled(callApiFx, { scope, params: {} }); + + expect(scope.getState($error)).toEqual( + configurationError({ + reason: 'Invalid URL', + validationErrors: ['"LOL KEK" is not valid URL'], + }) + ); + }); }); diff --git a/packages/core/src/fetch/api.ts b/packages/core/src/fetch/api.ts index 5b837a5d4..418ecfa08 100644 --- a/packages/core/src/fetch/api.ts +++ b/packages/core/src/fetch/api.ts @@ -16,6 +16,7 @@ import { import { NonOptionalKeys } from '../libs/lohyphen'; import { AbortError, + ConfigurationError, HttpError, InvalidDataError, NetworkError, @@ -143,6 +144,7 @@ interface ApiConfig, P> } export type ApiRequestError = + | ConfigurationError | TimeoutError | PreparationError | NetworkError diff --git a/packages/core/src/fetch/lib.ts b/packages/core/src/fetch/lib.ts index e1a7e0dfb..a6d87d3f8 100644 --- a/packages/core/src/fetch/lib.ts +++ b/packages/core/src/fetch/lib.ts @@ -1,3 +1,5 @@ +import { configurationError } from '../errors/create_error'; + export type FetchApiRecord = Record< string, string | string[] | number | boolean | null | undefined @@ -70,7 +72,8 @@ export function formatHeaders(headersRecord: FetchApiRecord): Headers { export function formatUrl( url: string, queryRecord: FetchApiRecord | string -): string { +): URL { + let urlString: string; let queryString: string; if (typeof queryRecord === 'string') { @@ -80,10 +83,19 @@ export function formatUrl( } if (!queryString) { - return url; + urlString = url; + } else { + urlString = `${url}?${queryString}`; } - return `${url}?${queryString}`; + try { + return new URL(urlString); + } catch (e) { + throw configurationError({ + reason: 'Invalid URL', + validationErrors: [`"${urlString}" is not valid URL`], + }); + } } function recordToUrlSearchParams(record: FetchApiRecord): URLSearchParams {