Skip to content

Commit 45a95cb

Browse files
authored
Allow standard schemas to validate endpoint values (#4864)
* add argument validation * add result, error, and meta schemas * declare error shape once * add type tests * pull types from spec instead of inlining * assign argSchema result * move common option to common options section * tweak test * add metaSchema type test * add test for inference from schemas * resultSchema -> responseSchema * add argSchema inference test * errorSchema -> errorResponse * add inference of raw result type from schema (or specifying from a third type parameter) * add api level and endpoint level handlers for schema failures * export NamedSchemaError * added skipSchemaValidation flag * persist raw result type after building
1 parent 9eb51e9 commit 45a95cb

File tree

12 files changed

+2464
-245
lines changed

12 files changed

+2464
-245
lines changed

packages/toolkit/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@
9999
"tsup": "^8.2.3",
100100
"tsx": "^4.19.0",
101101
"typescript": "^5.8.2",
102+
"valibot": "^1.0.0",
102103
"vite-tsconfig-paths": "^4.3.1",
103104
"vitest": "^1.6.0",
104105
"yargs": "^15.3.1"
@@ -124,6 +125,8 @@
124125
"react"
125126
],
126127
"dependencies": {
128+
"@standard-schema/spec": "^1.0.0",
129+
"@standard-schema/utils": "^0.3.0",
127130
"immer": "^10.0.3",
128131
"redux": "^5.0.1",
129132
"redux-thunk": "^3.1.0",

packages/toolkit/src/query/core/apiState.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ export type MutationKeys<Definitions extends EndpointDefinitions> = {
179179
}[keyof Definitions]
180180

181181
type BaseQuerySubState<
182-
D extends BaseEndpointDefinition<any, any, any>,
182+
D extends BaseEndpointDefinition<any, any, any, any>,
183183
DataType = ResultTypeFrom<D>,
184184
> = {
185185
/**
@@ -217,7 +217,7 @@ type BaseQuerySubState<
217217
}
218218

219219
export type QuerySubState<
220-
D extends BaseEndpointDefinition<any, any, any>,
220+
D extends BaseEndpointDefinition<any, any, any, any>,
221221
DataType = ResultTypeFrom<D>,
222222
> = Id<
223223
| ({ status: QueryStatus.fulfilled } & WithRequiredProp<
@@ -244,15 +244,17 @@ export type QuerySubState<
244244
export type InfiniteQueryDirection = 'forward' | 'backward'
245245

246246
export type InfiniteQuerySubState<
247-
D extends BaseEndpointDefinition<any, any, any>,
247+
D extends BaseEndpointDefinition<any, any, any, any>,
248248
> =
249249
D extends InfiniteQueryDefinition<any, any, any, any, any>
250250
? QuerySubState<D, InfiniteData<ResultTypeFrom<D>, PageParamFrom<D>>> & {
251251
direction?: InfiniteQueryDirection
252252
}
253253
: never
254254

255-
type BaseMutationSubState<D extends BaseEndpointDefinition<any, any, any>> = {
255+
type BaseMutationSubState<
256+
D extends BaseEndpointDefinition<any, any, any, any>,
257+
> = {
256258
requestId: string
257259
data?: ResultTypeFrom<D>
258260
error?:
@@ -265,8 +267,12 @@ type BaseMutationSubState<D extends BaseEndpointDefinition<any, any, any>> = {
265267
fulfilledTimeStamp?: number
266268
}
267269

268-
export type MutationSubState<D extends BaseEndpointDefinition<any, any, any>> =
269-
| (({ status: QueryStatus.fulfilled } & WithRequiredProp<
270+
export type MutationSubState<
271+
D extends BaseEndpointDefinition<any, any, any, any>,
272+
> =
273+
| (({
274+
status: QueryStatus.fulfilled
275+
} & WithRequiredProp<
270276
BaseMutationSubState<D>,
271277
'data' | 'fulfilledTimeStamp'
272278
>) & { error: undefined })

packages/toolkit/src/query/core/buildMiddleware/cacheLifecycle.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import type { ThunkDispatch, UnknownAction } from '@reduxjs/toolkit'
2-
import type { BaseQueryFn, BaseQueryMeta } from '../../baseQueryTypes'
2+
import type {
3+
BaseQueryFn,
4+
BaseQueryMeta,
5+
BaseQueryResult,
6+
} from '../../baseQueryTypes'
37
import type { BaseEndpointDefinition } from '../../endpointDefinitions'
48
import { DefinitionType, isAnyQueryDefinition } from '../../endpointDefinitions'
59
import type { QueryCacheKey, RootState } from '../apiState'
@@ -32,7 +36,8 @@ export interface QueryBaseLifecycleApi<
3236
{ type: DefinitionType.query } & BaseEndpointDefinition<
3337
QueryArg,
3438
BaseQuery,
35-
ResultType
39+
ResultType,
40+
BaseQueryResult<BaseQuery>
3641
>
3742
>
3843
/**
@@ -55,7 +60,8 @@ export type MutationBaseLifecycleApi<
5560
{ type: DefinitionType.mutation } & BaseEndpointDefinition<
5661
QueryArg,
5762
BaseQuery,
58-
ResultType
63+
ResultType,
64+
BaseQueryResult<BaseQuery>
5965
>
6066
>
6167
}

packages/toolkit/src/query/core/buildThunks.ts

Lines changed: 119 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import type {
2828
QueryDefinition,
2929
ResultDescription,
3030
ResultTypeFrom,
31+
SchemaFailureHandler,
32+
SchemaFailureInfo,
3133
} from '../endpointDefinitions'
3234
import {
3335
calculateProvidedBy,
@@ -65,6 +67,7 @@ import {
6567
isRejectedWithValue,
6668
SHOULD_AUTOBATCH,
6769
} from './rtkImports'
70+
import { parseWithSchema, NamedSchemaError } from '../standardSchema'
6871

6972
export type BuildThunksApiEndpointQuery<
7073
Definition extends QueryDefinition<any, any, any, any, any>,
@@ -329,6 +332,8 @@ export function buildThunks<
329332
api,
330333
assertTagType,
331334
selectors,
335+
onSchemaFailure,
336+
skipSchemaValidation: globalSkipSchemaValidation,
332337
}: {
333338
baseQuery: BaseQuery
334339
reducerPath: ReducerPath
@@ -337,6 +342,8 @@ export function buildThunks<
337342
api: Api<BaseQuery, Definitions, ReducerPath, any>
338343
assertTagType: AssertTagTypes
339344
selectors: AllSelectors
345+
onSchemaFailure: SchemaFailureHandler | undefined
346+
skipSchemaValidation: boolean | undefined
340347
}) {
341348
type State = RootState<any, string, ReducerPath>
342349

@@ -491,10 +498,14 @@ export function buildThunks<
491498
},
492499
) => {
493500
const endpointDefinition = endpointDefinitions[arg.endpointName]
501+
const { metaSchema, skipSchemaValidation = globalSkipSchemaValidation } =
502+
endpointDefinition
494503

495504
try {
496-
let transformResponse: TransformCallback =
497-
getTransformCallbackForEndpoint(endpointDefinition, 'transformResponse')
505+
let transformResponse = getTransformCallbackForEndpoint(
506+
endpointDefinition,
507+
'transformResponse',
508+
)
498509

499510
const baseQueryApi = {
500511
signal,
@@ -551,7 +562,16 @@ export function buildThunks<
551562
finalQueryArg: unknown,
552563
): Promise<QueryReturnValue> {
553564
let result: QueryReturnValue
554-
const { extraOptions } = endpointDefinition
565+
const { extraOptions, argSchema, rawResponseSchema, responseSchema } =
566+
endpointDefinition
567+
568+
if (argSchema && !skipSchemaValidation) {
569+
finalQueryArg = await parseWithSchema(
570+
argSchema,
571+
finalQueryArg,
572+
'argSchema',
573+
)
574+
}
555575

556576
if (forceQueryFn) {
557577
// upsertQueryData relies on this to pass in the user-provided value
@@ -606,13 +626,34 @@ export function buildThunks<
606626

607627
if (result.error) throw new HandledError(result.error, result.meta)
608628

609-
const transformedResponse = await transformResponse(
610-
result.data,
629+
let { data } = result
630+
631+
if (rawResponseSchema && !skipSchemaValidation) {
632+
data = await parseWithSchema(
633+
rawResponseSchema,
634+
result.data,
635+
'rawResponseSchema',
636+
)
637+
}
638+
639+
let transformedResponse = await transformResponse(
640+
data,
611641
result.meta,
612642
finalQueryArg,
613643
)
614644

615-
return { ...result, data: transformedResponse }
645+
if (responseSchema && !skipSchemaValidation) {
646+
transformedResponse = await parseWithSchema(
647+
responseSchema,
648+
transformedResponse,
649+
'responseSchema',
650+
)
651+
}
652+
653+
return {
654+
...result,
655+
data: transformedResponse,
656+
}
616657
}
617658

618659
if (
@@ -698,6 +739,14 @@ export function buildThunks<
698739
finalQueryReturnValue = await executeRequest(arg.originalArgs)
699740
}
700741

742+
if (metaSchema && !skipSchemaValidation && finalQueryReturnValue.meta) {
743+
finalQueryReturnValue.meta = await parseWithSchema(
744+
metaSchema,
745+
finalQueryReturnValue.meta,
746+
'metaSchema',
747+
)
748+
}
749+
701750
// console.log('Final result: ', transformedData)
702751
return fulfillWithValue(
703752
finalQueryReturnValue.data,
@@ -707,40 +756,78 @@ export function buildThunks<
707756
}),
708757
)
709758
} catch (error) {
710-
let catchedError = error
711-
if (catchedError instanceof HandledError) {
712-
let transformErrorResponse: TransformCallback =
713-
getTransformCallbackForEndpoint(
759+
try {
760+
let caughtError = error
761+
if (caughtError instanceof HandledError) {
762+
let transformErrorResponse = getTransformCallbackForEndpoint(
714763
endpointDefinition,
715764
'transformErrorResponse',
716765
)
766+
const { rawErrorResponseSchema, errorResponseSchema } =
767+
endpointDefinition
768+
769+
let { value, meta } = caughtError
770+
771+
if (rawErrorResponseSchema && !skipSchemaValidation) {
772+
value = await parseWithSchema(
773+
rawErrorResponseSchema,
774+
value,
775+
'rawErrorResponseSchema',
776+
)
777+
}
778+
779+
if (metaSchema && !skipSchemaValidation) {
780+
meta = await parseWithSchema(metaSchema, meta, 'metaSchema')
781+
}
717782

718-
try {
719-
return rejectWithValue(
720-
await transformErrorResponse(
721-
catchedError.value,
722-
catchedError.meta,
783+
try {
784+
let transformedErrorResponse = await transformErrorResponse(
785+
value,
786+
meta,
723787
arg.originalArgs,
724-
),
725-
addShouldAutoBatch({ baseQueryMeta: catchedError.meta }),
726-
)
727-
} catch (e) {
728-
catchedError = e
788+
)
789+
if (errorResponseSchema && !skipSchemaValidation) {
790+
transformedErrorResponse = await parseWithSchema(
791+
errorResponseSchema,
792+
transformedErrorResponse,
793+
'errorResponseSchema',
794+
)
795+
}
796+
797+
return rejectWithValue(
798+
transformedErrorResponse,
799+
addShouldAutoBatch({ baseQueryMeta: meta }),
800+
)
801+
} catch (e) {
802+
caughtError = e
803+
}
729804
}
730-
}
731-
if (
732-
typeof process !== 'undefined' &&
733-
process.env.NODE_ENV !== 'production'
734-
) {
735-
console.error(
736-
`An unhandled error occurred processing a request for the endpoint "${arg.endpointName}".
805+
if (
806+
typeof process !== 'undefined' &&
807+
process.env.NODE_ENV !== 'production'
808+
) {
809+
console.error(
810+
`An unhandled error occurred processing a request for the endpoint "${arg.endpointName}".
737811
In the case of an unhandled error, no tags will be "provided" or "invalidated".`,
738-
catchedError,
739-
)
740-
} else {
741-
console.error(catchedError)
812+
caughtError,
813+
)
814+
} else {
815+
console.error(caughtError)
816+
}
817+
throw caughtError
818+
} catch (error) {
819+
if (error instanceof NamedSchemaError) {
820+
const info: SchemaFailureInfo = {
821+
endpoint: arg.endpointName,
822+
arg: arg.originalArgs,
823+
type: arg.type,
824+
queryCacheKey: arg.type === 'query' ? arg.queryCacheKey : undefined,
825+
}
826+
endpointDefinition.onSchemaFailure?.(error, info)
827+
onSchemaFailure?.(error, info)
828+
}
829+
throw error
742830
}
743-
throw catchedError
744831
}
745832
}
746833

packages/toolkit/src/query/core/module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,8 @@ export const coreModule = ({
516516
refetchOnFocus,
517517
refetchOnReconnect,
518518
invalidationBehavior,
519+
onSchemaFailure,
520+
skipSchemaValidation,
519521
},
520522
context,
521523
) {
@@ -582,6 +584,8 @@ export const coreModule = ({
582584
serializeQueryArgs,
583585
assertTagType,
584586
selectors,
587+
onSchemaFailure,
588+
skipSchemaValidation,
585589
})
586590

587591
const { reducer, actions: sliceActions } = buildSlice({

packages/toolkit/src/query/createApi.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { defaultSerializeQueryArgs } from './defaultSerializeQueryArgs'
66
import type {
77
EndpointBuilder,
88
EndpointDefinitions,
9+
SchemaFailureHandler,
910
} from './endpointDefinitions'
1011
import {
1112
DefinitionType,
@@ -212,6 +213,9 @@ export interface CreateApiOptions<
212213
NoInfer<TagTypes>,
213214
NoInfer<ReducerPath>
214215
>
216+
217+
onSchemaFailure?: SchemaFailureHandler
218+
skipSchemaValidation?: boolean
215219
}
216220

217221
export type CreateApi<Modules extends ModuleName> = {

0 commit comments

Comments
 (0)