diff --git a/docs/rtk-query/api/createApi.mdx b/docs/rtk-query/api/createApi.mdx index d881735cfb..f21329bc41 100644 --- a/docs/rtk-query/api/createApi.mdx +++ b/docs/rtk-query/api/createApi.mdx @@ -221,6 +221,20 @@ export type QueryDefinition< updateCachedData, // available for query endpoints only }: QueryCacheLifecycleApi, ): Promise + + argSchema?: StandardSchemaV1 + + /* only available with `query`, not `queryFn` */ + rawResponseSchema?: StandardSchemaV1> + + responseSchema?: StandardSchemaV1 + + /* only available with `query`, not `queryFn` */ + rawErrorResponseSchema?: StandardSchemaV1> + + errorResponseSchema?: StandardSchemaV1> + + metaSchema?: StandardSchemaV1> } ``` @@ -469,6 +483,36 @@ You can set this globally in `createApi`, but you can also override the default If you specify `track: false` when manually dispatching queries, RTK Query will not be able to automatically refetch for you. ::: +### `onSchemaFailure` + +[summary](docblock://query/createApi.ts?token=CreateApiOptions.onSchemaFailure) + +[examples](docblock://query/createApi.ts?token=CreateApiOptions.onSchemaFailure) + +:::note +You can set this globally in `createApi`, but you can also override the default value and have more granular control by passing `onSchemaFailure` to each individual endpoint definition. +::: + +### `catchSchemaFailure` + +[summary](docblock://query/createApi.ts?token=CreateApiOptions.catchSchemaFailure) + +[examples](docblock://query/createApi.ts?token=CreateApiOptions.catchSchemaFailure) + +:::note +You can set this globally in `createApi`, but you can also override the default value and have more granular control by passing `catchSchemaFailure` to each individual endpoint definition. +::: + +### `skipSchemaValidation` + +[summary](docblock://query/createApi.ts?token=CreateApiOptions.skipSchemaValidation) + +[examples](docblock://query/createApi.ts?token=CreateApiOptions.skipSchemaValidation) + +:::note +You can set this globally in `createApi`, but you can also override the default value and have more granular control by passing `skipSchemaValidation` to each individual endpoint definition. +::: + ## Endpoint Definition Parameters ### `query` @@ -792,6 +836,82 @@ async function onCacheEntryAdded( ): Promise ``` +### Schema Validation + +Endpoints can have schemas for runtime validation of query args, responses, and errors. Any [Standard Schema](https://standardschema.dev/) compliant library can be used. + +When used with TypeScript, schemas can also be used to [infer the type of that value instead of having to declare it](../usage-with-typescript.mdx#schema-validation). + +:::warning + +By default, schema failures are treated as _fatal_, meaning that normal error handling such as tag invalidation will not be executed. + +In order for schema failures to be treated as non-fatal, you must provide a [`catchSchemaFailure`](#catchschemafailure) function, to convert the schema failure into an error shape matching the base query errors. + +```ts title="catchSchemaFailure example" no-transpile +const api = createApi({ + baseQuery: fetchBaseQuery({ baseUrl: '/' }), + catchSchemaFailure: (error, info) => ({ + status: 'CUSTOM_ERROR', + error: error.schemaName + ' failed validation', + data: error, + }), + endpoints: (build) => ({ + // ... + }), +}) +``` + +::: + +#### `argSchema` + +_(optional)_ + +[summary](docblock://query/endpointDefinitions.ts?token=CommonEndpointDefinition.argSchema) + +[examples](docblock://query/endpointDefinitions.ts?token=CommonEndpointDefinition.argSchema) + +#### `responseSchema` + +_(optional)_ + +[summary](docblock://query/endpointDefinitions.ts?token=CommonEndpointDefinition.responseSchema) + +[examples](docblock://query/endpointDefinitions.ts?token=CommonEndpointDefinition.responseSchema) + +#### `rawResponseSchema` + +_(optional, not applicable with `queryFn`)_ + +[summary](docblock://query/endpointDefinitions.ts?token=EndpointDefinitionWithQuery.rawResponseSchema) + +[examples](docblock://query/endpointDefinitions.ts?token=EndpointDefinitionWithQuery.rawResponseSchema) + +#### `errorResponseSchema` + +_(optional)_ + +[summary](docblock://query/endpointDefinitions.ts?token=CommonEndpointDefinition.errorResponseSchema) + +[examples](docblock://query/endpointDefinitions.ts?token=CommonEndpointDefinition.errorResponseSchema) + +#### `rawErrorResponseSchema` + +_(optional, not applicable with `queryFn`)_ + +[summary](docblock://query/endpointDefinitions.ts?token=EndpointDefinitionWithQuery.rawErrorResponseSchema) + +[examples](docblock://query/endpointDefinitions.ts?token=EndpointDefinitionWithQuery.rawErrorResponseSchema) + +#### `metaSchema` + +_(optional)_ + +[summary](docblock://query/endpointDefinitions.ts?token=CommonEndpointDefinition.metaSchema) + +[examples](docblock://query/endpointDefinitions.ts?token=CommonEndpointDefinition.metaSchema) + ## Return value See [the "created Api" API reference](./created-api/overview) diff --git a/docs/rtk-query/usage-with-typescript.mdx b/docs/rtk-query/usage-with-typescript.mdx index a9f67bbbd9..0067858640 100644 --- a/docs/rtk-query/usage-with-typescript.mdx +++ b/docs/rtk-query/usage-with-typescript.mdx @@ -703,3 +703,135 @@ function AddPost() { ) } ``` + +## Schema Validation + +Endpoints can have schemas for runtime validation of query args, responses, and errors. Any [Standard Schema](https://standardschema.dev/) compliant library can be used. See [API reference](./api/createApi.mdx#schema-validation) for full list of available schemas. + +When following the default approach of explicitly specifying type parameters for queries and mutations, the schemas will be required to match the types provided. + +```ts title="Explicitly typed endpoint" no-transpile +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' +import * as v from 'valibot' + +const postSchema = v.object({ + id: v.number(), + name: v.string(), +}) +type Post = v.InferOutput + +const api = createApi({ + baseQuery: fetchBaseQuery({ baseUrl: '/' }), + endpoints: (build) => ({ + getPost: build.query({ + query: ({ id }) => `/post/${id}`, + responseSchema: postSchema, // errors if type mismatch + }), + }), +}) +``` + +Schemas can also be used as a source of inference, meaning that the type parameters can be omitted. + +```ts title="Implicitly typed endpoint" no-transpile +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' +import * as v from 'valibot' + +const postSchema = v.object({ + id: v.number(), + name: v.string(), +}) +type Post = v.InferOutput + +const api = createApi({ + baseQuery: fetchBaseQuery({ baseUrl: '/' }), + endpoints: (build) => ({ + getPost: build.query({ + // infer arg from here + query: ({ id }: { id: number }) => `/post/${id}`, + // infer result from here + responseSchema: postSchema, + }), + getTransformedPost: build.query({ + // infer arg from here + query: ({ id }: { id: number }) => `/post/${id}`, + // infer untransformed result from here + rawResponseSchema: postSchema, + // infer transformed result from here + transformResponse: (response) => ({ + ...response, + published_at: new Date(response.published_at), + }), + }), + }), +}) +``` + +:::warning + +Schemas should _not_ perform any transformation that would change the type of the value. + +```ts title="Incorrect usage" no-transpile +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' +import * as v from 'valibot' +import { titleCase } from 'lodash' + +const postSchema = v.object({ + id: v.number(), + name: v.pipe( + v.string(), + v.transform(titleCase), // fine - string -> string + ), + published_at: v.pipe( + v.string(), + // highlight-next-line + v.transform((s) => new Date(s)), // not allowed! + v.date(), + ), +}) +type Post = v.InferOutput + +const api = createApi({ + baseQuery: fetchBaseQuery({ baseUrl: '/' }), + endpoints: (build) => ({ + getPost: build.query({ + query: ({ id }) => `/post/${id}`, + responseSchema: postSchema, + }), + }), +}) +``` + +Instead, transformation should be done with `transformResponse` and `transformErrorResponse` (when using `query`) or inside `queryFn` (when using `queryFn`). + +```ts title="Correct usage" no-transpile +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' +import * as v from 'valibot' + +const postSchema = v.object({ + id: v.number(), + name: v.string(), + published_at: v.string(), +}) +type RawPost = v.InferOutput +type Post = Omit & { published_at: Date } + +const api = createApi({ + baseQuery: fetchBaseQuery({ baseUrl: '/' }), + endpoints: (build) => ({ + getPost: build.query({ + query: ({ id }) => `/post/${id}`, + // use rawResponseSchema to validate *before* transformation + rawResponseSchema: postSchema, + // highlight-start + transformResponse: (response) => ({ + ...response, + published_at: new Date(response.published_at), + }), + // highlight-end + }), + }), +}) +``` + +::: diff --git a/docs/rtk-query/usage/infinite-queries.mdx b/docs/rtk-query/usage/infinite-queries.mdx index c43dd02572..2509913ef9 100644 --- a/docs/rtk-query/usage/infinite-queries.mdx +++ b/docs/rtk-query/usage/infinite-queries.mdx @@ -531,3 +531,61 @@ const projectsApi = createApi({ }), }) ``` + +## Runtime Validation using Schemas + +Endpoints can use any [Standard Schema](https://standardschema.dev/) compliant library for runtime validation of query args, responses, and errors. See [API reference](../api/createApi.mdx#schema-validation) for full list of available schemas. + +When used with TypeScript, schemas can also be used to [infer the type of that value instead of having to declare it](../usage-with-typescript.mdx#schema-validation). + +Most commonly, you'll want to use `responseSchema` to validate the response from the server (or `rawResponseSchema` when using `transformResponse`). + +```ts title="Using responseSchema" no-transpile +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' +import * as v from 'valibot' + +const pokemonSchema = v.object({ + id: v.number(), + name: v.string(), +}) +type Pokemon = v.InferOutput +const transformedPokemonSchema = v.object({ + ...pokemonSchema.entries, + id: v.string(), +}) +type TransformedPokemon = v.InferOutput + +const api = createApi({ + baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com/pokemon' }), + endpoints: (build) => ({ + getInfinitePokemon: build.infiniteQuery({ + query: ({ queryArg, pageParam }) => `type/${queryArg}?page=${pageParam}`, + // argSchema for infinite queries must have both queryArg and pageParam + argSchema: v.object({ + queryArg: v.string(), + pageParam: v.number(), + }), + responseSchema: v.array(pokemonSchema), + }), + getTransformedPokemon: build.infiniteQuery< + TransformedPokemon[], + string, + number + >({ + query: ({ queryArg, pageParam }) => `type/${queryArg}?page=${pageParam}`, + argSchema: v.object({ + queryArg: v.string(), + pageParam: v.number(), + }), + rawResponseSchema: v.array(pokemonSchema), + transformResponse: (response) => + response.map((pokemon) => ({ + ...pokemon, + id: String(pokemon.id), + })), + // responseSchema can still be provided, to validate the transformed response + responseSchema: v.array(transformedPokemonSchema), + }), + }), +}) +``` diff --git a/docs/rtk-query/usage/mutations.mdx b/docs/rtk-query/usage/mutations.mdx index 15a3b2e8f4..af50bb522d 100644 --- a/docs/rtk-query/usage/mutations.mdx +++ b/docs/rtk-query/usage/mutations.mdx @@ -326,3 +326,62 @@ export const { allow="geolocation; microphone; camera; midi; vr; accelerometer; gyroscope; payment; ambient-light-sensor; encrypted-media; usb" sandbox="allow-modals allow-forms allow-popups allow-scripts allow-same-origin" > + +## Runtime Validation using Schemas + +Endpoints can use any [Standard Schema](https://standardschema.dev/) compliant library for runtime validation of query args, responses, and errors. See [API reference](../api/createApi.mdx#schema-validation) for full list of available schemas. + +When used with TypeScript, schemas can also be used to [infer the type of that value instead of having to declare it](../usage-with-typescript.mdx#schema-validation). + +Most commonly, you'll want to use `responseSchema` to validate the response from the server (or `rawResponseSchema` when using `transformResponse`). + +```ts title="Using responseSchema" no-transpile +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' +import * as v from 'valibot' + +const postSchema = v.object({ + id: v.number(), + name: v.string(), + published_at: v.string(), +}) +type Post = v.InferOutput +const transformedPost = v.object({ + ...postSchema.entries, + published_at: v.date(), +}) +type TransformedPost = v.InferOutput + +const api = createApi({ + baseQuery: fetchBaseQuery({ baseUrl: '/' }), + endpoints: (build) => ({ + updatePost: build.mutation>({ + query(data) { + const { id, ...body } = data + return { + url: `post/${id}`, + method: 'PUT', + body, + } + }, + responseSchema: postSchema, + }), + updatePostWithTransform: build.mutation>({ + query(data) { + const { id, ...body } = data + return { + url: `post/${id}`, + method: 'PUT', + body, + } + }, + rawResponseSchema: postSchema, + transformResponse: (response) => ({ + ...response, + published_at: new Date(response.published_at), + }), + // responseSchema can still be provided, to validate the transformed response + responseSchema: transformedPost, + }), + }), +}) +``` diff --git a/docs/rtk-query/usage/queries.mdx b/docs/rtk-query/usage/queries.mdx index 6f185aedf9..205c1fe0a0 100644 --- a/docs/rtk-query/usage/queries.mdx +++ b/docs/rtk-query/usage/queries.mdx @@ -350,6 +350,50 @@ const { status, data, error, refetch } = dispatch( ::: +## Runtime Validation using Schemas + +Endpoints can use any [Standard Schema](https://standardschema.dev/) compliant library for runtime validation of query args, responses, and errors. See [API reference](../api/createApi.mdx#schema-validation) for full list of available schemas. + +When used with TypeScript, schemas can also be used to [infer the type of that value instead of having to declare it](../usage-with-typescript.mdx#schema-validation). + +Most commonly, you'll want to use `responseSchema` to validate the response from the server (or `rawResponseSchema` when using `transformResponse`). + +```ts title="Using responseSchema" no-transpile +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' +import * as v from 'valibot' + +const postSchema = v.object({ + id: v.number(), + name: v.string(), +}) +type Post = v.InferOutput +const transformedPost = v.object({ + ...postSchema.entries, + published_at: v.date(), +}) +type TransformedPost = v.InferOutput + +const api = createApi({ + baseQuery: fetchBaseQuery({ baseUrl: '/' }), + endpoints: (build) => ({ + getPost: build.query({ + query: ({ id }) => `/post/${id}`, + responseSchema: postSchema, + }), + getTransformedPost: build.query({ + query: ({ id }) => `/post/${id}`, + rawResponseSchema: postSchema, + transformResponse: (response) => ({ + ...response, + published_at: new Date(response.published_at), + }), + // responseSchema can still be provided, to validate the transformed response + responseSchema: transformedPost, + }), + }), +}) +``` + ## Example: Observing caching behavior This example demonstrates request deduplication and caching behavior: diff --git a/packages/toolkit/src/query/core/buildThunks.ts b/packages/toolkit/src/query/core/buildThunks.ts index e7bd105987..5652f17588 100644 --- a/packages/toolkit/src/query/core/buildThunks.ts +++ b/packages/toolkit/src/query/core/buildThunks.ts @@ -28,6 +28,7 @@ import type { QueryDefinition, ResultDescription, ResultTypeFrom, + SchemaFailureConverter, SchemaFailureHandler, SchemaFailureInfo, } from '../endpointDefinitions' @@ -333,6 +334,7 @@ export function buildThunks< assertTagType, selectors, onSchemaFailure, + catchSchemaFailure: globalCatchSchemaFailure, skipSchemaValidation: globalSkipSchemaValidation, }: { baseQuery: BaseQuery @@ -343,6 +345,7 @@ export function buildThunks< assertTagType: AssertTagTypes selectors: AllSelectors onSchemaFailure: SchemaFailureHandler | undefined + catchSchemaFailure: SchemaFailureConverter | undefined skipSchemaValidation: boolean | undefined }) { type State = RootState @@ -570,6 +573,7 @@ export function buildThunks< argSchema, finalQueryArg, 'argSchema', + {}, // we don't have a meta yet, so we can't pass it ) } @@ -633,6 +637,7 @@ export function buildThunks< rawResponseSchema, result.data, 'rawResponseSchema', + result.meta, ) } @@ -647,6 +652,7 @@ export function buildThunks< responseSchema, transformedResponse, 'responseSchema', + result.meta, ) } @@ -744,6 +750,7 @@ export function buildThunks< metaSchema, finalQueryReturnValue.meta, 'metaSchema', + finalQueryReturnValue.meta, ) } @@ -756,78 +763,87 @@ export function buildThunks< }), ) } catch (error) { - try { - let caughtError = error - if (caughtError instanceof HandledError) { - let transformErrorResponse = getTransformCallbackForEndpoint( - endpointDefinition, - 'transformErrorResponse', - ) - const { rawErrorResponseSchema, errorResponseSchema } = - endpointDefinition + let caughtError = error + if (caughtError instanceof HandledError) { + let transformErrorResponse = getTransformCallbackForEndpoint( + endpointDefinition, + 'transformErrorResponse', + ) + const { rawErrorResponseSchema, errorResponseSchema } = + endpointDefinition - let { value, meta } = caughtError + let { value, meta } = caughtError + try { if (rawErrorResponseSchema && !skipSchemaValidation) { value = await parseWithSchema( rawErrorResponseSchema, value, 'rawErrorResponseSchema', + meta, ) } if (metaSchema && !skipSchemaValidation) { - meta = await parseWithSchema(metaSchema, meta, 'metaSchema') + meta = await parseWithSchema(metaSchema, meta, 'metaSchema', meta) } - - try { - let transformedErrorResponse = await transformErrorResponse( - value, - meta, - arg.originalArgs, - ) - if (errorResponseSchema && !skipSchemaValidation) { - transformedErrorResponse = await parseWithSchema( - errorResponseSchema, - transformedErrorResponse, - 'errorResponseSchema', - ) - } - - return rejectWithValue( + let transformedErrorResponse = await transformErrorResponse( + value, + meta, + arg.originalArgs, + ) + if (errorResponseSchema && !skipSchemaValidation) { + transformedErrorResponse = await parseWithSchema( + errorResponseSchema, transformedErrorResponse, - addShouldAutoBatch({ baseQueryMeta: meta }), + 'errorResponseSchema', + meta, ) - } catch (e) { - caughtError = e } - } - if ( - typeof process !== 'undefined' && - process.env.NODE_ENV !== 'production' - ) { - console.error( - `An unhandled error occurred processing a request for the endpoint "${arg.endpointName}". -In the case of an unhandled error, no tags will be "provided" or "invalidated".`, - caughtError, + + return rejectWithValue( + transformedErrorResponse, + addShouldAutoBatch({ baseQueryMeta: meta }), ) - } else { - console.error(caughtError) + } catch (e) { + caughtError = e } - throw caughtError - } catch (error) { - if (error instanceof NamedSchemaError) { + } + try { + if (caughtError instanceof NamedSchemaError) { const info: SchemaFailureInfo = { endpoint: arg.endpointName, arg: arg.originalArgs, type: arg.type, queryCacheKey: arg.type === 'query' ? arg.queryCacheKey : undefined, } - endpointDefinition.onSchemaFailure?.(error, info) - onSchemaFailure?.(error, info) + endpointDefinition.onSchemaFailure?.(caughtError, info) + onSchemaFailure?.(caughtError, info) + const { catchSchemaFailure = globalCatchSchemaFailure } = + endpointDefinition + if (catchSchemaFailure) { + return rejectWithValue( + catchSchemaFailure(caughtError, info), + addShouldAutoBatch({ baseQueryMeta: caughtError._bqMeta }), + ) + } } - throw error + } catch (e) { + caughtError = e + } + if ( + typeof process !== 'undefined' && + process.env.NODE_ENV !== 'production' + ) { + console.error( + `An unhandled error occurred processing a request for the endpoint "${arg.endpointName}". +In the case of an unhandled error, no tags will be "provided" or "invalidated".`, + caughtError, + ) + } else { + console.error(caughtError) } + throw caughtError } } diff --git a/packages/toolkit/src/query/core/module.ts b/packages/toolkit/src/query/core/module.ts index d3e3a8b0a1..7747d23c10 100644 --- a/packages/toolkit/src/query/core/module.ts +++ b/packages/toolkit/src/query/core/module.ts @@ -517,6 +517,7 @@ export const coreModule = ({ refetchOnReconnect, invalidationBehavior, onSchemaFailure, + catchSchemaFailure, skipSchemaValidation, }, context, @@ -585,6 +586,7 @@ export const coreModule = ({ assertTagType, selectors, onSchemaFailure, + catchSchemaFailure, skipSchemaValidation, }) diff --git a/packages/toolkit/src/query/createApi.ts b/packages/toolkit/src/query/createApi.ts index 31fde60be8..8e1b937aed 100644 --- a/packages/toolkit/src/query/createApi.ts +++ b/packages/toolkit/src/query/createApi.ts @@ -6,6 +6,7 @@ import { defaultSerializeQueryArgs } from './defaultSerializeQueryArgs' import type { EndpointBuilder, EndpointDefinitions, + SchemaFailureConverter, SchemaFailureHandler, } from './endpointDefinitions' import { @@ -214,7 +215,89 @@ export interface CreateApiOptions< NoInfer > + /** + * A function that is called when a schema validation fails. + * + * Gets called with a `NamedSchemaError` and an object containing the endpoint name, the type of the endpoint, the argument passed to the endpoint, and the query cache key (if applicable). + * + * `NamedSchemaError` has the following properties: + * - `issues`: an array of issues that caused the validation to fail + * - `value`: the value that was passed to the schema + * - `schemaName`: the name of the schema that was used to validate the value (e.g. `argSchema`) + * + * @example + * ```ts + * // codeblock-meta no-transpile + * import { createApi } from '@reduxjs/toolkit/query/react' + * import * as v from "valibot" + * + * const api = createApi({ + * baseQuery: fetchBaseQuery({ baseUrl: '/' }), + * endpoints: (build) => ({ + * getPost: build.query({ + * query: ({ id }) => `/post/${id}`, + * }), + * }), + * onSchemaFailure: (error, info) => { + * console.error(error, info) + * }, + * }) + * ``` + */ onSchemaFailure?: SchemaFailureHandler + + /** + * Convert a schema validation failure into an error shape matching base query errors. + * + * When not provided, schema failures are treated as fatal, and normal error handling such as tag invalidation will not be executed. + * + * @example + * ```ts + * // codeblock-meta no-transpile + * import { createApi } from '@reduxjs/toolkit/query/react' + * import * as v from "valibot" + * + * const api = createApi({ + * baseQuery: fetchBaseQuery({ baseUrl: '/' }), + * endpoints: (build) => ({ + * getPost: build.query({ + * query: ({ id }) => `/post/${id}`, + * responseSchema: v.object({ id: v.number(), name: v.string() }), + * }), + * }), + * catchSchemaFailure: (error, info) => ({ + * status: "CUSTOM_ERROR", + * error: error.schemaName + " failed validation", + * data: error.issues, + * }), + * }) + * ``` + */ + catchSchemaFailure?: SchemaFailureConverter + + /** + * Defaults to `false`. + * + * If set to `true`, will skip schema validation for all endpoints, unless overridden by the endpoint. + * + * @example + * ```ts + * // codeblock-meta no-transpile + * import { createApi } from '@reduxjs/toolkit/query/react' + * import * as v from "valibot" + * + * const api = createApi({ + * baseQuery: fetchBaseQuery({ baseUrl: '/' }), + * skipSchemaValidation: process.env.NODE_ENV === "test", // skip schema validation in tests, since we'll be mocking the response + * endpoints: (build) => ({ + * getPost: build.query({ + * query: ({ id }) => `/post/${id}`, + * responseSchema: v.object({ id: v.number(), name: v.string() }), + * }), + * }) + * }) + * ``` + */ skipSchemaValidation?: boolean } diff --git a/packages/toolkit/src/query/endpointDefinitions.ts b/packages/toolkit/src/query/endpointDefinitions.ts index c19488c1a2..f881e78d88 100644 --- a/packages/toolkit/src/query/endpointDefinitions.ts +++ b/packages/toolkit/src/query/endpointDefinitions.ts @@ -40,6 +40,7 @@ import type { import { isNotNullish } from './utils' import type { NamedSchemaError } from './standardSchema' +const rawResultType = /* @__PURE__ */ Symbol() const resultType = /* @__PURE__ */ Symbol() const baseQuery = /* @__PURE__ */ Symbol() @@ -55,7 +56,12 @@ export type SchemaFailureHandler = ( info: SchemaFailureInfo, ) => void -type EndpointDefinitionWithQuery< +export type SchemaFailureConverter = ( + error: NamedSchemaError, + info: SchemaFailureInfo, +) => BaseQueryError + +export type EndpointDefinitionWithQuery< QueryArg, BaseQuery extends BaseQueryFn, ResultType, @@ -118,14 +124,58 @@ type EndpointDefinitionWithQuery< arg: QueryArg, ): unknown - /** A schema for the result *before* it's passed to `transformResponse` */ + /** + * A schema for the result *before* it's passed to `transformResponse`. + * + * @example + * ```ts + * // codeblock-meta no-transpile + * import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' + * import * as v from "valibot" + * + * const postSchema = v.object({ id: v.number(), name: v.string() }) + * type Post = v.InferOutput + * + * const api = createApi({ + * baseQuery: fetchBaseQuery({ baseUrl: '/' }), + * endpoints: (build) => ({ + * getPostName: build.query({ + * query: ({ id }) => `/post/${id}`, + * rawResponseSchema: postSchema, + * transformResponse: (post) => post.name, + * }), + * }) + * }) + * ``` + */ rawResponseSchema?: StandardSchemaV1 - /** A schema for the error object returned by the `query` or `queryFn`, *before* it's passed to `transformErrorResponse` */ + /** + * A schema for the error object returned by the `query` or `queryFn`, *before* it's passed to `transformErrorResponse`. + * + * @example + * ```ts + * // codeblock-meta no-transpile + * import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' + * import * as v from "valibot" + * import {customBaseQuery, baseQueryErrorSchema} from "./customBaseQuery" + * + * const api = createApi({ + * baseQuery: customBaseQuery, + * endpoints: (build) => ({ + * getPost: build.query({ + * query: ({ id }) => `/post/${id}`, + * rawErrorResponseSchema: baseQueryErrorSchema, + * transformErrorResponse: (error) => error.data, + * }), + * }) + * }) + * ``` + */ rawErrorResponseSchema?: StandardSchemaV1> } -type EndpointDefinitionWithQueryFn< +export type EndpointDefinitionWithQueryFn< QueryArg, BaseQuery extends BaseQueryFn, ResultType, @@ -193,32 +243,102 @@ type BaseEndpointTypes = { ResultType: ResultType } -export type BaseEndpointDefinition< +interface CommonEndpointDefinition< QueryArg, BaseQuery extends BaseQueryFn, ResultType, - RawResultType extends BaseQueryResult = BaseQueryResult, -> = ( - | ([CastAny, {}>] extends [NEVER] - ? never - : EndpointDefinitionWithQuery< - QueryArg, - BaseQuery, - ResultType, - RawResultType - >) - | EndpointDefinitionWithQueryFn -) & { - /** A schema for the arguments to be passed to the `query` or `queryFn` */ +> { + /** + * A schema for the arguments to be passed to the `query` or `queryFn`. + * + * @example + * ```ts + * // codeblock-meta no-transpile + * import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' + * import * as v from "valibot" + * + * const api = createApi({ + * baseQuery: fetchBaseQuery({ baseUrl: '/' }), + * endpoints: (build) => ({ + * getPost: build.query({ + * query: ({ id }) => `/post/${id}`, + * argSchema: v.object({ id: v.number() }), + * }), + * }) + * }) + * ``` + */ argSchema?: StandardSchemaV1 - /** A schema for the result (including `transformResponse` if provided) */ + /** + * A schema for the result (including `transformResponse` if provided). + * + * @example + * ```ts + * // codeblock-meta no-transpile + * import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' + * import * as v from "valibot" + * + * const postSchema = v.object({ id: v.number(), name: v.string() }) + * type Post = v.InferOutput + * + * const api = createApi({ + * baseQuery: fetchBaseQuery({ baseUrl: '/' }), + * endpoints: (build) => ({ + * getPost: build.query({ + * query: ({ id }) => `/post/${id}`, + * responseSchema: postSchema, + * }), + * }) + * }) + * ``` + */ responseSchema?: StandardSchemaV1 - /** A schema for the error object returned by the `query` or `queryFn` (including `transformErrorResponse` if provided) */ + /** + * A schema for the error object returned by the `query` or `queryFn` (including `transformErrorResponse` if provided). + * + * @example + * ```ts + * // codeblock-meta no-transpile + * import { createApi } from '@reduxjs/toolkit/query/react' + * import * as v from "valibot" + * import { customBaseQuery, baseQueryErrorSchema } from "./customBaseQuery" + * + * const api = createApi({ + * baseQuery: customBaseQuery, + * endpoints: (build) => ({ + * getPost: build.query({ + * query: ({ id }) => `/post/${id}`, + * errorResponseSchema: baseQueryErrorSchema, + * }), + * }) + * }) + * ``` + */ errorResponseSchema?: StandardSchemaV1> - /** A schema for the `meta` property returned by the `query` or `queryFn` */ + /** + * A schema for the `meta` property returned by the `query` or `queryFn`. + * + * @example + * ```ts + * // codeblock-meta no-transpile + * import { createApi } from '@reduxjs/toolkit/query/react' + * import * as v from "valibot" + * import { customBaseQuery, baseQueryMetaSchema } from "./customBaseQuery" + * + * const api = createApi({ + * baseQuery: customBaseQuery, + * endpoints: (build) => ({ + * getPost: build.query({ + * query: ({ id }) => `/post/${id}`, + * metaSchema: baseQueryMetaSchema, + * }), + * }) + * }) + * ``` + */ metaSchema?: StandardSchemaV1> /** @@ -235,14 +355,117 @@ export type BaseEndpointDefinition< */ structuralSharing?: boolean + /** + * A function that is called when a schema validation fails. + * + * Gets called with a `NamedSchemaError` and an object containing the endpoint name, the type of the endpoint, the argument passed to the endpoint, and the query cache key (if applicable). + * + * `NamedSchemaError` has the following properties: + * - `issues`: an array of issues that caused the validation to fail + * - `value`: the value that was passed to the schema + * - `schemaName`: the name of the schema that was used to validate the value (e.g. `argSchema`) + * + * @example + * ```ts + * // codeblock-meta no-transpile + * import { createApi } from '@reduxjs/toolkit/query/react' + * import * as v from "valibot" + * + * const api = createApi({ + * baseQuery: fetchBaseQuery({ baseUrl: '/' }), + * endpoints: (build) => ({ + * getPost: build.query({ + * query: ({ id }) => `/post/${id}`, + * onSchemaFailure: (error, info) => { + * console.error(error, info) + * }, + * }), + * }) + * }) + * ``` + */ onSchemaFailure?: SchemaFailureHandler + + /** + * Convert a schema validation failure into an error shape matching base query errors. + * + * When not provided, schema failures are treated as fatal, and normal error handling such as tag invalidation will not be executed. + * + * @example + * ```ts + * // codeblock-meta no-transpile + * import { createApi } from '@reduxjs/toolkit/query/react' + * import * as v from "valibot" + * + * const api = createApi({ + * baseQuery: fetchBaseQuery({ baseUrl: '/' }), + * endpoints: (build) => ({ + * getPost: build.query({ + * query: ({ id }) => `/post/${id}`, + * responseSchema: v.object({ id: v.number(), name: v.string() }), + * catchSchemaFailure: (error, info) => ({ + * status: "CUSTOM_ERROR", + * error: error.schemaName + " failed validation", + * data: error.issues, + * }), + * }), + * }), + * }) + * ``` + */ + catchSchemaFailure?: SchemaFailureConverter + + /** + * Defaults to `false`. + * + * If set to `true`, will skip schema validation for this endpoint. + * Overrides the global setting. + * + * @example + * ```ts + * // codeblock-meta no-transpile + * import { createApi } from '@reduxjs/toolkit/query/react' + * import * as v from "valibot" + * + * const api = createApi({ + * baseQuery: fetchBaseQuery({ baseUrl: '/' }), + * endpoints: (build) => ({ + * getPost: build.query({ + * query: ({ id }) => `/post/${id}`, + * responseSchema: v.object({ id: v.number(), name: v.string() }), + * skipSchemaValidation: process.env.NODE_ENV === "test", // skip schema validation in tests, since we'll be mocking the response + * }), + * }) + * }) + * ``` + */ skipSchemaValidation?: boolean +} - /* phantom type */ - [resultType]?: ResultType - /* phantom type */ - [baseQuery]?: BaseQuery -} & HasRequiredProps< +export type BaseEndpointDefinition< + QueryArg, + BaseQuery extends BaseQueryFn, + ResultType, + RawResultType extends BaseQueryResult = BaseQueryResult, +> = ( + | ([CastAny, {}>] extends [NEVER] + ? never + : EndpointDefinitionWithQuery< + QueryArg, + BaseQuery, + ResultType, + RawResultType + >) + | EndpointDefinitionWithQueryFn +) & + CommonEndpointDefinition & { + /* phantom type */ + [rawResultType]?: RawResultType + /* phantom type */ + [resultType]?: ResultType + /* phantom type */ + [baseQuery]?: BaseQuery + } & HasRequiredProps< BaseQueryExtraOptions, { extraOptions: BaseQueryExtraOptions }, { extraOptions?: BaseQueryExtraOptions } diff --git a/packages/toolkit/src/query/index.ts b/packages/toolkit/src/query/index.ts index 609e0d10d5..96a06166bf 100644 --- a/packages/toolkit/src/query/index.ts +++ b/packages/toolkit/src/query/index.ts @@ -47,6 +47,7 @@ export type { TagTypesFromApi, UpdateDefinitions, SchemaFailureHandler, + SchemaFailureConverter, SchemaFailureInfo, } from './endpointDefinitions' export { fetchBaseQuery } from './fetchBaseQuery' diff --git a/packages/toolkit/src/query/react/buildHooks.ts b/packages/toolkit/src/query/react/buildHooks.ts index 86ee665d82..ae78077a6d 100644 --- a/packages/toolkit/src/query/react/buildHooks.ts +++ b/packages/toolkit/src/query/react/buildHooks.ts @@ -161,7 +161,7 @@ export type TypedUseQueryHookResult< > = TypedUseQueryStateResult & TypedUseQuerySubscriptionResult -type UseQuerySubscriptionOptions = SubscriptionOptions & { +export type UseQuerySubscriptionOptions = SubscriptionOptions & { /** * Prevents a query from automatically running. * diff --git a/packages/toolkit/src/query/standardSchema.ts b/packages/toolkit/src/query/standardSchema.ts index e85e6b7e2a..75dd22f0d5 100644 --- a/packages/toolkit/src/query/standardSchema.ts +++ b/packages/toolkit/src/query/standardSchema.ts @@ -6,6 +6,7 @@ export class NamedSchemaError extends SchemaError { issues: readonly StandardSchemaV1.Issue[], public readonly value: any, public readonly schemaName: string, + public readonly _bqMeta: any, ) { super(issues) } @@ -15,10 +16,11 @@ export async function parseWithSchema( schema: Schema, data: unknown, schemaName: string, + bqMeta: any, ): Promise> { const result = await schema['~standard'].validate(data) if (result.issues) { - throw new NamedSchemaError(result.issues, data, schemaName) + throw new NamedSchemaError(result.issues, data, schemaName, bqMeta) } return result.value } diff --git a/packages/toolkit/src/query/tests/createApi.test.ts b/packages/toolkit/src/query/tests/createApi.test.ts index 9fc0b11ded..824977a3b7 100644 --- a/packages/toolkit/src/query/tests/createApi.test.ts +++ b/packages/toolkit/src/query/tests/createApi.test.ts @@ -11,6 +11,7 @@ import type { FetchBaseQueryError, FetchBaseQueryMeta, OverrideResultType, + SchemaFailureConverter, SerializeQueryArgs, TagTypesFromApi, } from '@reduxjs/toolkit/query' @@ -1198,6 +1199,16 @@ describe('timeout behavior', () => { }) describe('endpoint schemas', () => { + const schemaConverter: SchemaFailureConverter< + ReturnType + > = (error) => { + return { + status: 'CUSTOM_ERROR', + error: error.schemaName + ' failed validation', + data: error.issues, + } + } + const serializedSchemaError = { name: 'SchemaError', message: expect.any(String), @@ -1237,17 +1248,26 @@ describe('endpoint schemas', () => { const makeApi = ({ globalSkip, endpointSkip, - }: { globalSkip?: boolean; endpointSkip?: boolean } = {}) => + globalCatch, + endpointCatch, + }: { + globalSkip?: boolean + endpointSkip?: boolean + globalCatch?: boolean + endpointCatch?: boolean + } = {}) => createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com' }), onSchemaFailure: onSchemaFailureGlobal, skipSchemaValidation: globalSkip, + catchSchemaFailure: globalCatch ? schemaConverter : undefined, endpoints: (build) => ({ query: build.query({ query: ({ id }) => `/post/${id}`, argSchema: v.object({ id: v.number() }), onSchemaFailure: onSchemaFailureEndpoint, skipSchemaValidation: endpointSkip, + catchSchemaFailure: endpointCatch ? schemaConverter : undefined, }), }), }) @@ -1319,21 +1339,76 @@ describe('endpoint schemas', () => { expect(result?.error).toEqual(serializedSchemaError) }) + + test('can be converted to a standard error object at global level', async () => { + const api = makeApi({ globalCatch: true }) + const storeRef = setupApiStore(api, undefined, { + withoutTestLifecycles: true, + }) + + const result = await storeRef.store.dispatch( + // @ts-expect-error + api.endpoints.query.initiate({ id: '1' }), + ) + + expect(result?.error).toEqual({ + status: 'CUSTOM_ERROR', + error: 'argSchema failed validation', + data: expect.any(Array), + }) + + expectFailureHandlersToHaveBeenCalled({ + schemaName: 'argSchema', + value: { id: '1' }, + arg: { id: '1' }, + }) + }) + test('can be converted to a standard error object at endpoint level', async () => { + const api = makeApi({ endpointCatch: true }) + const storeRef = setupApiStore(api, undefined, { + withoutTestLifecycles: true, + }) + + const result = await storeRef.store.dispatch( + // @ts-expect-error + api.endpoints.query.initiate({ id: '1' }), + ) + + expect(result?.error).toEqual({ + status: 'CUSTOM_ERROR', + error: 'argSchema failed validation', + data: expect.any(Array), + }) + expectFailureHandlersToHaveBeenCalled({ + schemaName: 'argSchema', + value: { id: '1' }, + arg: { id: '1' }, + }) + }) }) describe('rawResponseSchema', () => { const makeApi = ({ globalSkip, endpointSkip, - }: { globalSkip?: boolean; endpointSkip?: boolean } = {}) => + globalCatch, + endpointCatch, + }: { + globalSkip?: boolean + endpointSkip?: boolean + globalCatch?: boolean + endpointCatch?: boolean + } = {}) => createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com' }), onSchemaFailure: onSchemaFailureGlobal, + catchSchemaFailure: globalCatch ? schemaConverter : undefined, skipSchemaValidation: globalSkip, endpoints: (build) => ({ query: build.query<{ success: boolean }, void>({ query: () => '/success', rawResponseSchema: v.object({ value: v.literal('success!') }), onSchemaFailure: onSchemaFailureEndpoint, + catchSchemaFailure: endpointCatch ? schemaConverter : undefined, skipSchemaValidation: endpointSkip, }), }), @@ -1373,15 +1448,61 @@ describe('endpoint schemas', () => { ) expect(result?.error).toBeUndefined() }) + test('can be converted to a standard error object at global level', async () => { + const api = makeApi({ globalCatch: true }) + const storeRef = setupApiStore(api, undefined, { + withoutTestLifecycles: true, + }) + const result = await storeRef.store.dispatch( + api.endpoints.query.initiate(), + ) + expect(result?.error).toEqual({ + status: 'CUSTOM_ERROR', + error: 'rawResponseSchema failed validation', + data: expect.any(Array), + }) + expectFailureHandlersToHaveBeenCalled({ + schemaName: 'rawResponseSchema', + value: { value: 'success' }, + arg: undefined, + }) + }) + test('can be converted to a standard error object at endpoint level', async () => { + const api = makeApi({ endpointCatch: true }) + const storeRef = setupApiStore(api, undefined, { + withoutTestLifecycles: true, + }) + const result = await storeRef.store.dispatch( + api.endpoints.query.initiate(), + ) + expect(result?.error).toEqual({ + status: 'CUSTOM_ERROR', + error: 'rawResponseSchema failed validation', + data: expect.any(Array), + }) + expectFailureHandlersToHaveBeenCalled({ + schemaName: 'rawResponseSchema', + value: { value: 'success' }, + arg: undefined, + }) + }) }) describe('responseSchema', () => { const makeApi = ({ globalSkip, endpointSkip, - }: { globalSkip?: boolean; endpointSkip?: boolean } = {}) => + globalCatch, + endpointCatch, + }: { + globalSkip?: boolean + endpointSkip?: boolean + globalCatch?: boolean + endpointCatch?: boolean + } = {}) => createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com' }), onSchemaFailure: onSchemaFailureGlobal, + catchSchemaFailure: globalCatch ? schemaConverter : undefined, skipSchemaValidation: globalSkip, endpoints: (build) => ({ query: build.query<{ success: boolean }, void>({ @@ -1389,6 +1510,7 @@ describe('endpoint schemas', () => { transformResponse: () => ({ success: false }), responseSchema: v.object({ success: v.literal(true) }), onSchemaFailure: onSchemaFailureEndpoint, + catchSchemaFailure: endpointCatch ? schemaConverter : undefined, skipSchemaValidation: endpointSkip, }), }), @@ -1429,15 +1551,61 @@ describe('endpoint schemas', () => { ) expect(result?.error).toBeUndefined() }) + test('can be converted to a standard error object at global level', async () => { + const api = makeApi({ globalCatch: true }) + const storeRef = setupApiStore(api, undefined, { + withoutTestLifecycles: true, + }) + const result = await storeRef.store.dispatch( + api.endpoints.query.initiate(), + ) + expect(result?.error).toEqual({ + status: 'CUSTOM_ERROR', + error: 'responseSchema failed validation', + data: expect.any(Array), + }) + expectFailureHandlersToHaveBeenCalled({ + schemaName: 'responseSchema', + value: { success: false }, + arg: undefined, + }) + }) + test('can be converted to a standard error object at endpoint level', async () => { + const api = makeApi({ endpointCatch: true }) + const storeRef = setupApiStore(api, undefined, { + withoutTestLifecycles: true, + }) + const result = await storeRef.store.dispatch( + api.endpoints.query.initiate(), + ) + expect(result?.error).toEqual({ + status: 'CUSTOM_ERROR', + error: 'responseSchema failed validation', + data: expect.any(Array), + }) + expectFailureHandlersToHaveBeenCalled({ + schemaName: 'responseSchema', + value: { success: false }, + arg: undefined, + }) + }) }) describe('rawErrorResponseSchema', () => { const makeApi = ({ globalSkip, endpointSkip, - }: { globalSkip?: boolean; endpointSkip?: boolean } = {}) => + globalCatch, + endpointCatch, + }: { + globalSkip?: boolean + endpointSkip?: boolean + globalCatch?: boolean + endpointCatch?: boolean + } = {}) => createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com' }), onSchemaFailure: onSchemaFailureGlobal, + catchSchemaFailure: globalCatch ? schemaConverter : undefined, skipSchemaValidation: globalSkip, endpoints: (build) => ({ query: build.query<{ success: boolean }, void>({ @@ -1447,6 +1615,7 @@ describe('endpoint schemas', () => { data: v.unknown(), }), onSchemaFailure: onSchemaFailureEndpoint, + catchSchemaFailure: endpointCatch ? schemaConverter : undefined, skipSchemaValidation: endpointSkip, }), }), @@ -1486,15 +1655,61 @@ describe('endpoint schemas', () => { ) expect(result?.error).not.toEqual(serializedSchemaError) }) + test('can be converted to a standard error object at global level', async () => { + const api = makeApi({ globalCatch: true }) + const storeRef = setupApiStore(api, undefined, { + withoutTestLifecycles: true, + }) + const result = await storeRef.store.dispatch( + api.endpoints.query.initiate(), + ) + expect(result?.error).toEqual({ + status: 'CUSTOM_ERROR', + error: 'rawErrorResponseSchema failed validation', + data: expect.any(Array), + }) + expectFailureHandlersToHaveBeenCalled({ + schemaName: 'rawErrorResponseSchema', + value: { status: 500, data: { value: 'error' } }, + arg: undefined, + }) + }) + test('can be converted to a standard error object at endpoint level', async () => { + const api = makeApi({ endpointCatch: true }) + const storeRef = setupApiStore(api, undefined, { + withoutTestLifecycles: true, + }) + const result = await storeRef.store.dispatch( + api.endpoints.query.initiate(), + ) + expect(result?.error).toEqual({ + status: 'CUSTOM_ERROR', + error: 'rawErrorResponseSchema failed validation', + data: expect.any(Array), + }) + expectFailureHandlersToHaveBeenCalled({ + schemaName: 'rawErrorResponseSchema', + value: { status: 500, data: { value: 'error' } }, + arg: undefined, + }) + }) }) describe('errorResponseSchema', () => { const makeApi = ({ globalSkip, endpointSkip, - }: { globalSkip?: boolean; endpointSkip?: boolean } = {}) => + globalCatch, + endpointCatch, + }: { + globalSkip?: boolean + endpointSkip?: boolean + globalCatch?: boolean + endpointCatch?: boolean + } = {}) => createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com' }), onSchemaFailure: onSchemaFailureGlobal, + catchSchemaFailure: globalCatch ? schemaConverter : undefined, skipSchemaValidation: globalSkip, endpoints: (build) => ({ query: build.query<{ success: boolean }, void>({ @@ -1510,6 +1725,7 @@ describe('endpoint schemas', () => { data: v.unknown(), }), onSchemaFailure: onSchemaFailureEndpoint, + catchSchemaFailure: endpointCatch ? schemaConverter : undefined, skipSchemaValidation: endpointSkip, }), }), @@ -1553,15 +1769,69 @@ describe('endpoint schemas', () => { ) expect(result?.error).not.toEqual(serializedSchemaError) }) + test('can be converted to a standard error object at global level', async () => { + const api = makeApi({ globalCatch: true }) + const storeRef = setupApiStore(api, undefined, { + withoutTestLifecycles: true, + }) + const result = await storeRef.store.dispatch( + api.endpoints.query.initiate(), + ) + expect(result?.error).toEqual({ + status: 'CUSTOM_ERROR', + error: 'errorResponseSchema failed validation', + data: expect.any(Array), + }) + expectFailureHandlersToHaveBeenCalled({ + schemaName: 'errorResponseSchema', + value: { + status: 'CUSTOM_ERROR', + error: 'whoops', + data: { status: 500, data: { value: 'error' } }, + }, + arg: undefined, + }) + }) + test('can be converted to a standard error object at endpoint level', async () => { + const api = makeApi({ endpointCatch: true }) + const storeRef = setupApiStore(api, undefined, { + withoutTestLifecycles: true, + }) + const result = await storeRef.store.dispatch( + api.endpoints.query.initiate(), + ) + expect(result?.error).toEqual({ + status: 'CUSTOM_ERROR', + error: 'errorResponseSchema failed validation', + data: expect.any(Array), + }) + expectFailureHandlersToHaveBeenCalled({ + schemaName: 'errorResponseSchema', + value: { + status: 'CUSTOM_ERROR', + error: 'whoops', + data: { status: 500, data: { value: 'error' } }, + }, + arg: undefined, + }) + }) }) describe('metaSchema', () => { const makeApi = ({ globalSkip, endpointSkip, - }: { globalSkip?: boolean; endpointSkip?: boolean } = {}) => + globalCatch, + endpointCatch, + }: { + globalSkip?: boolean + endpointSkip?: boolean + globalCatch?: boolean + endpointCatch?: boolean + } = {}) => createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com' }), onSchemaFailure: onSchemaFailureGlobal, + catchSchemaFailure: globalCatch ? schemaConverter : undefined, skipSchemaValidation: globalSkip, endpoints: (build) => ({ query: build.query<{ success: boolean }, void>({ @@ -1572,6 +1842,7 @@ describe('endpoint schemas', () => { timestamp: v.number(), }), onSchemaFailure: onSchemaFailureEndpoint, + catchSchemaFailure: endpointCatch ? schemaConverter : undefined, skipSchemaValidation: endpointSkip, }), }), @@ -1614,5 +1885,49 @@ describe('endpoint schemas', () => { ) expect(result?.error).toBeUndefined() }) + test('can be converted to a standard error object at global level', async () => { + const api = makeApi({ globalCatch: true }) + const storeRef = setupApiStore(api, undefined, { + withoutTestLifecycles: true, + }) + const result = await storeRef.store.dispatch( + api.endpoints.query.initiate(), + ) + expect(result?.error).toEqual({ + status: 'CUSTOM_ERROR', + error: 'metaSchema failed validation', + data: expect.any(Array), + }) + expectFailureHandlersToHaveBeenCalled({ + schemaName: 'metaSchema', + value: { + request: expect.any(Request), + response: expect.any(Response), + }, + arg: undefined, + }) + }) + test('can be converted to a standard error object at endpoint level', async () => { + const api = makeApi({ endpointCatch: true }) + const storeRef = setupApiStore(api, undefined, { + withoutTestLifecycles: true, + }) + const result = await storeRef.store.dispatch( + api.endpoints.query.initiate(), + ) + expect(result?.error).toEqual({ + status: 'CUSTOM_ERROR', + error: 'metaSchema failed validation', + data: expect.any(Array), + }) + expectFailureHandlersToHaveBeenCalled({ + schemaName: 'metaSchema', + value: { + request: expect.any(Request), + response: expect.any(Response), + }, + arg: undefined, + }) + }) }) }) diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index b042530c54..a850b8eaa9 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -33,6 +33,7 @@ const config: Config = { 'index.ts', 'query/index.ts', 'query/createApi.ts', + 'query/endpointDefinitions.ts', 'query/react/index.ts', 'query/react/ApiProvider.tsx', ],