Skip to content

Commit d81dd20

Browse files
committed
add argument validation
1 parent 18ddd7e commit d81dd20

File tree

6 files changed

+166
-2
lines changed

6 files changed

+166
-2
lines changed

packages/toolkit/package.json

Lines changed: 2 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.5.4",
102+
"valibot": "^1.0.0-rc.2",
102103
"vite-tsconfig-paths": "^4.3.1",
103104
"vitest": "^1.6.0",
104105
"yargs": "^15.3.1"
@@ -124,6 +125,7 @@
124125
"react"
125126
],
126127
"dependencies": {
128+
"@standard-schema/utils": "^0.3.0",
127129
"immer": "^10.0.3",
128130
"redux": "^5.0.1",
129131
"redux-thunk": "^3.1.0",

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import {
6464
isRejectedWithValue,
6565
SHOULD_AUTOBATCH,
6666
} from './rtkImports'
67+
import { parseWithSchema } from '../standardSchema'
6768

6869
export type BuildThunksApiEndpointQuery<
6970
Definition extends QueryDefinition<any, any, any, any, any>,
@@ -547,7 +548,11 @@ export function buildThunks<
547548
finalQueryArg: unknown,
548549
): Promise<QueryReturnValue> {
549550
let result: QueryReturnValue
550-
const { extraOptions } = endpointDefinition
551+
const { extraOptions, argSchema } = endpointDefinition
552+
553+
if (argSchema) {
554+
await parseWithSchema(argSchema, finalQueryArg)
555+
}
551556

552557
if (forceQueryFn) {
553558
// upsertQueryData relies on this to pass in the user-provided value
@@ -645,7 +650,6 @@ export function buildThunks<
645650
isForcedQueryNeedingRefetch || !cachedData ? blankData : cachedData
646651
) as InfiniteData<unknown, unknown>
647652

648-
649653
// If the thunk specified a direction and we do have at least one page,
650654
// fetch the next or previous page
651655
if ('direction' in arg && arg.direction && existingData.pages.length) {

packages/toolkit/src/query/endpointDefinitions.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import type {
3737
UnwrapPromise,
3838
} from './tsHelpers'
3939
import { isNotNullish } from './utils'
40+
import type { StandardSchemaV1 } from './standardSchema'
4041

4142
const resultType = /* @__PURE__ */ Symbol()
4243
const baseQuery = /* @__PURE__ */ Symbol()
@@ -115,6 +116,9 @@ type EndpointDefinitionWithQuery<
115116
* @see https://redux-toolkit.js.org/api/other-exports#copywithstructuralsharing
116117
*/
117118
structuralSharing?: boolean
119+
120+
/** A schema for the result *before* it's passed to `transformResponse` */
121+
rawResultSchema?: StandardSchemaV1<BaseQueryResult<BaseQuery>>
118122
}
119123

120124
type EndpointDefinitionWithQueryFn<
@@ -175,6 +179,7 @@ type EndpointDefinitionWithQueryFn<
175179
query?: never
176180
transformResponse?: never
177181
transformErrorResponse?: never
182+
rawResultSchema?: never
178183
/**
179184
* Defaults to `true`.
180185
*
@@ -206,6 +211,15 @@ export type BaseEndpointDefinition<
206211
: EndpointDefinitionWithQuery<QueryArg, BaseQuery, ResultType>)
207212
| EndpointDefinitionWithQueryFn<QueryArg, BaseQuery, ResultType>
208213
) & {
214+
/** A schema for the arguments to be passed to the `query` or `queryFn` */
215+
argSchema?: StandardSchemaV1<QueryArg>
216+
217+
/** A schema for the result (including `transformResponse` if provided) */
218+
resultSchema?: StandardSchemaV1<ResultType>
219+
220+
/** A schema for the error object returned by the `query` or `queryFn` */
221+
errorSchema?: StandardSchemaV1<BaseQueryError<BaseQuery>>
222+
209223
/* phantom type */
210224
[resultType]?: ResultType
211225
/* phantom type */
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { SchemaError } from '@standard-schema/utils'
2+
3+
/** The Standard Schema interface. */
4+
export interface StandardSchemaV1<Input = unknown, Output = Input> {
5+
/** The Standard Schema properties. */
6+
readonly '~standard': StandardSchemaV1.Props<Input, Output>
7+
}
8+
9+
export declare namespace StandardSchemaV1 {
10+
/** The Standard Schema properties interface. */
11+
export interface Props<Input = unknown, Output = Input> {
12+
/** The version number of the standard. */
13+
readonly version: 1
14+
/** The vendor name of the schema library. */
15+
readonly vendor: string
16+
/** Validates unknown input values. */
17+
readonly validate: (
18+
value: unknown,
19+
) => Result<Output> | Promise<Result<Output>>
20+
/** Inferred types associated with the schema. */
21+
readonly types?: Types<Input, Output> | undefined
22+
}
23+
24+
/** The result interface of the validate function. */
25+
export type Result<Output> = SuccessResult<Output> | FailureResult
26+
27+
/** The result interface if validation succeeds. */
28+
export interface SuccessResult<Output> {
29+
/** The typed output value. */
30+
readonly value: Output
31+
/** The non-existent issues. */
32+
readonly issues?: undefined
33+
}
34+
35+
/** The result interface if validation fails. */
36+
export interface FailureResult {
37+
/** The issues of failed validation. */
38+
readonly issues: ReadonlyArray<Issue>
39+
}
40+
41+
/** The issue interface of the failure output. */
42+
export interface Issue {
43+
/** The error message of the issue. */
44+
readonly message: string
45+
/** The path of the issue, if any. */
46+
readonly path?: ReadonlyArray<PropertyKey | PathSegment> | undefined
47+
}
48+
49+
/** The path segment interface of the issue. */
50+
export interface PathSegment {
51+
/** The key representing a path segment. */
52+
readonly key: PropertyKey
53+
}
54+
55+
/** The Standard Schema types interface. */
56+
export interface Types<Input = unknown, Output = Input> {
57+
/** The input type of the schema. */
58+
readonly input: Input
59+
/** The output type of the schema. */
60+
readonly output: Output
61+
}
62+
63+
/** Infers the input type of a Standard Schema. */
64+
export type InferInput<Schema extends StandardSchemaV1> = NonNullable<
65+
Schema['~standard']['types']
66+
>['input']
67+
68+
/** Infers the output type of a Standard Schema. */
69+
export type InferOutput<Schema extends StandardSchemaV1> = NonNullable<
70+
Schema['~standard']['types']
71+
>['output']
72+
}
73+
74+
export async function parseWithSchema<Schema extends StandardSchemaV1>(
75+
schema: Schema,
76+
data: unknown,
77+
): Promise<StandardSchemaV1.InferOutput<Schema>> {
78+
let result = schema['~standard'].validate(data)
79+
if (result instanceof Promise) result = await result
80+
if (result.issues) throw new SchemaError(result.issues)
81+
return result.value
82+
}

packages/toolkit/src/query/tests/createApi.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
getSerializedHeaders,
55
setupApiStore,
66
} from '@internal/tests/utils/helpers'
7+
import type { SerializedError } from '@reduxjs/toolkit'
78
import { configureStore, createAction, createReducer } from '@reduxjs/toolkit'
89
import type {
910
DefinitionsFromApi,
@@ -15,6 +16,7 @@ import type {
1516
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query'
1617
import { HttpResponse, delay, http } from 'msw'
1718
import nodeFetch from 'node-fetch'
19+
import * as v from 'valibot'
1820

1921
beforeAll(() => {
2022
vi.stubEnv('NODE_ENV', 'development')
@@ -1183,3 +1185,42 @@ describe('timeout behavior', () => {
11831185
})
11841186
})
11851187
})
1188+
1189+
describe('endpoint schemas', () => {
1190+
test("can be used to validate the endpoint's arguments", async () => {
1191+
server.use(
1192+
http.get('https://example.com/success/1', () => HttpResponse.json({})),
1193+
)
1194+
1195+
const api = createApi({
1196+
baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com' }),
1197+
endpoints: (build) => ({
1198+
query: build.query<unknown, { id: number }>({
1199+
query: ({ id }) => `/success/${id}`,
1200+
argSchema: v.object({ id: v.number() }),
1201+
}),
1202+
}),
1203+
})
1204+
1205+
const storeRef = setupApiStore(api, undefined, {
1206+
withoutTestLifecycles: true,
1207+
})
1208+
1209+
const result = await storeRef.store.dispatch(
1210+
api.endpoints.query.initiate({ id: 1 }),
1211+
)
1212+
1213+
expect(result?.error).toBeUndefined()
1214+
1215+
const invalidResult = await storeRef.store.dispatch(
1216+
// @ts-expect-error
1217+
api.endpoints.query.initiate({ id: '1' }),
1218+
)
1219+
1220+
expect(invalidResult?.error).toEqual<SerializedError>({
1221+
name: 'SchemaError',
1222+
message: expect.any(String),
1223+
stack: expect.any(String),
1224+
})
1225+
})
1226+
})

yarn.lock

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8133,6 +8133,7 @@ __metadata:
81338133
"@phryneas/ts-version": "npm:^1.0.2"
81348134
"@size-limit/file": "npm:^11.0.1"
81358135
"@size-limit/webpack": "npm:^11.0.1"
8136+
"@standard-schema/utils": "npm:^0.3.0"
81368137
"@testing-library/dom": "npm:^10.4.0"
81378138
"@testing-library/react": "npm:^16.0.1"
81388139
"@testing-library/react-render-stream": "npm:^1.0.3"
@@ -8180,6 +8181,7 @@ __metadata:
81808181
tsup: "npm:^8.2.3"
81818182
tsx: "npm:^4.19.0"
81828183
typescript: "npm:^5.5.4"
8184+
valibot: "npm:^1.0.0-rc.2"
81838185
vite-tsconfig-paths: "npm:^4.3.1"
81848186
vitest: "npm:^1.6.0"
81858187
yargs: "npm:^15.3.1"
@@ -8843,6 +8845,13 @@ __metadata:
88438845
languageName: node
88448846
linkType: hard
88458847

8848+
"@standard-schema/utils@npm:^0.3.0":
8849+
version: 0.3.0
8850+
resolution: "@standard-schema/utils@npm:0.3.0"
8851+
checksum: 10/7084f875d322792f2e0a5904009434c8374b9345b09ba89828b68fd56fa3c2b366d35bf340d9e8c72736ef01793c2f70d350c372ed79845dc3566c58d34b4b51
8852+
languageName: node
8853+
linkType: hard
8854+
88468855
"@styled-system/core@npm:5.1.2":
88478856
version: 5.1.2
88488857
resolution: "@styled-system/core@npm:5.1.2"
@@ -33975,6 +33984,18 @@ __metadata:
3397533984
languageName: node
3397633985
linkType: hard
3397733986

33987+
"valibot@npm:^1.0.0-rc.2":
33988+
version: 1.0.0-rc.2
33989+
resolution: "valibot@npm:1.0.0-rc.2"
33990+
peerDependencies:
33991+
typescript: ">=5"
33992+
peerDependenciesMeta:
33993+
typescript:
33994+
optional: true
33995+
checksum: 10/f6ef9c5b8e6ac7162176489d6ea567513be7b1e78bbad8deecfe0f905d4063cdfc4382412f3c263dc65077a585198a5b0d454695a45bf77ff0a1cd2f644347ad
33996+
languageName: node
33997+
linkType: hard
33998+
3397833999
"valid-url@npm:1.0.9, valid-url@npm:^1.0.9":
3397934000
version: 1.0.9
3398034001
resolution: "valid-url@npm:1.0.9"

0 commit comments

Comments
 (0)