Skip to content

Commit fd3bdb6

Browse files
committed
fix(openapi-fetch): reject excess request body and params properties
1 parent 290255d commit fd3bdb6

9 files changed

Lines changed: 173 additions & 5 deletions

File tree

.changeset/tough-coins-listen.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"openapi-fetch": patch
3+
---
4+
5+
reject excess request body and params properties

packages/openapi-fetch/src/index.d.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,36 @@ export interface DefaultParamsOption {
9090
};
9191
}
9292

93+
type DeepExact<Actual, Shape> = Actual extends Shape
94+
? Actual extends readonly (infer ActualItem)[]
95+
? Shape extends readonly (infer ShapeItem)[]
96+
? readonly DeepExact<ActualItem, ShapeItem>[]
97+
: Actual
98+
: Actual extends object
99+
? Shape extends object
100+
? { [K in keyof Actual]: K extends keyof Shape ? DeepExact<Actual[K], Shape[K]> : never }
101+
: Actual
102+
: Actual
103+
: never;
104+
105+
type ExactOptionProperty<Init extends object, Key extends "params" | "body", Shape> = Key extends keyof Init
106+
? {} extends Pick<Init, Key>
107+
? { [K in Key]?: DeepExact<Exclude<Init[K], undefined>, Shape> | Extract<Init[K], undefined> }
108+
: { [K in Key]: DeepExact<Exclude<Init[K], undefined>, Shape> | Extract<Init[K], undefined> }
109+
: {};
110+
111+
type ExactParamsAndBody<Init, Operation> = Init extends undefined
112+
? Init
113+
: Init extends object
114+
? Omit<Init, "params" | "body"> &
115+
ExactOptionProperty<
116+
Init,
117+
"params",
118+
ParamsOption<Operation> extends { params?: infer Params } ? Params : never
119+
> &
120+
ExactOptionProperty<Init, "body", RequestBodyOption<Operation> extends { body?: infer Body } ? Body : never>
121+
: Init;
122+
93123
export type ParamsOption<T> = T extends {
94124
parameters: any;
95125
}
@@ -208,7 +238,7 @@ export type ClientMethod<
208238
Media extends MediaType,
209239
> = <Path extends PathsWithMethod<Paths, Method>, Init extends MaybeOptionalInit<Paths[Path], Method>>(
210240
url: Path,
211-
...init: InitParam<Init>
241+
...init: InitParam<ExactParamsAndBody<Init, FilterKeys<Paths[Path], Method>>>
212242
) => Promise<FetchResponse<Paths[Path][Method], Init, Media>>;
213243

214244
export type ClientRequestMethod<Paths extends Record<string, Record<HttpMethod, {}>>, Media extends MediaType> = <
@@ -218,12 +248,12 @@ export type ClientRequestMethod<Paths extends Record<string, Record<HttpMethod,
218248
>(
219249
method: Method,
220250
url: Path,
221-
...init: InitParam<Init>
251+
...init: InitParam<ExactParamsAndBody<Init, FilterKeys<Paths[Path], Method>>>
222252
) => Promise<FetchResponse<Paths[Path][Method], Init, Media>>;
223253

224254
export type ClientForPath<PathInfo extends Record<string | number, any>, Media extends MediaType> = {
225255
[Method in keyof PathInfo as Uppercase<string & Method>]: <Init extends MaybeOptionalInit<PathInfo, Method>>(
226-
...init: InitParam<Init>
256+
...init: InitParam<ExactParamsAndBody<Init, FilterKeys<PathInfo, Method>>>
227257
) => Promise<FetchResponse<PathInfo[Method], Init, Media>>;
228258
};
229259

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { test } from "vitest";
2+
import createClient from "../../src/index.js";
3+
import type { paths } from "./schemas/common.js";
4+
5+
test("GET rejects undefined query params", () => {
6+
const client = createClient<paths>();
7+
8+
client.GET("/query-params", {
9+
params: {
10+
query: {
11+
array: [],
12+
// Regression test: extra query params should be rejected.
13+
// @ts-expect-error
14+
undefined_property: [],
15+
},
16+
},
17+
});
18+
});
19+
20+
test("client.request rejects undefined query params", () => {
21+
const client = createClient<paths>();
22+
23+
client.request("get", "/query-params", {
24+
params: {
25+
query: {
26+
array: [],
27+
// Regression test: extra query params should be rejected via client.request().
28+
// @ts-expect-error
29+
undefined_property: [],
30+
},
31+
},
32+
});
33+
});
34+
35+
test("GET rejects undefined path params", () => {
36+
const client = createClient<paths>();
37+
38+
client.GET("/resources/{id}", {
39+
params: {
40+
path: {
41+
id: 123,
42+
// Regression test: extra path params should be rejected.
43+
// @ts-expect-error
44+
undefined_property: 456,
45+
},
46+
},
47+
});
48+
});
49+
50+
test("client.request rejects undefined path params", () => {
51+
const client = createClient<paths>();
52+
53+
client.request("get", "/resources/{id}", {
54+
params: {
55+
path: {
56+
id: 123,
57+
// Regression test: extra path params should be rejected via client.request().
58+
// @ts-expect-error
59+
undefined_property: 456,
60+
},
61+
},
62+
});
63+
});

packages/openapi-fetch/test/common/schemas/common.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,8 @@ export interface paths {
672672
number?: number;
673673
boolean?: boolean;
674674
array?: string[];
675+
second_array?: string[];
676+
third_array?: string[];
675677
object?: {
676678
foo: string;
677679
bar: string;
@@ -688,6 +690,8 @@ export interface paths {
688690
number?: number;
689691
boolean?: boolean;
690692
array?: string[];
693+
second_array?: string[];
694+
third_array?: string[];
691695
object?: {
692696
foo: string;
693697
bar: string;

packages/openapi-fetch/test/common/schemas/common.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,18 @@ paths:
392392
type: array
393393
items:
394394
type: string
395+
- in: query
396+
name: second_array
397+
schema:
398+
type: array
399+
items:
400+
type: string
401+
- in: query
402+
name: third_array
403+
schema:
404+
type: array
405+
items:
406+
type: string
395407
- in: query
396408
name: object
397409
schema:
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { test } from "vitest";
2+
import createClient from "../../src/index.js";
3+
import type { paths } from "./schemas/post.js";
4+
5+
test("POST rejects undefined request body properties", () => {
6+
const client = createClient<paths>();
7+
8+
client.POST("/posts", {
9+
body: {
10+
title: "My Post",
11+
body: "Post body",
12+
publish_date: new Date("2024-06-06T12:00:00Z").getTime(),
13+
// Regression test for #1769: extra body fields must still error when required fields are present.
14+
// @ts-expect-error
15+
undefined_property: true,
16+
},
17+
});
18+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { test } from "vitest";
2+
import createClient from "../../src/index.js";
3+
import type { paths } from "./schemas/post.js";
4+
5+
test("client.request rejects undefined request body properties", () => {
6+
const client = createClient<paths>();
7+
8+
client.request("post", "/posts", {
9+
body: {
10+
title: "My Post",
11+
body: "Post body",
12+
publish_date: new Date("2024-06-06T12:00:00Z").getTime(),
13+
// Regression test for #1769 across client.request().
14+
// @ts-expect-error
15+
undefined_property: true,
16+
},
17+
});
18+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { test } from "vitest";
2+
import { createPathBasedClient } from "../../src/index.js";
3+
import type { paths } from "../http-methods/schemas/post.js";
4+
5+
test("path-based client rejects undefined request body properties", () => {
6+
const client = createPathBasedClient<paths>();
7+
8+
client["/posts"].POST({
9+
body: {
10+
title: "My Post",
11+
body: "Post body",
12+
publish_date: new Date("2024-06-06T12:00:00Z").getTime(),
13+
// Regression test for #1769 across path-based clients.
14+
// @ts-expect-error
15+
undefined_property: true,
16+
},
17+
});
18+
});

packages/openapi-react-query/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,12 +265,12 @@ export default function createClient<Paths extends {}, Media extends MediaType =
265265
mutationFn: async (init) => {
266266
const mth = method.toUpperCase() as Uppercase<typeof method>;
267267
const fn = client[mth] as ClientMethod<Paths, typeof method, Media>;
268-
const { data, error } = await fn(path, init as InitWithUnknowns<typeof init>);
268+
const { data, error } = await fn(path, init as any); // TODO: find a way to avoid as any
269269
if (error) {
270270
throw error;
271271
}
272272

273-
return data as Exclude<typeof data, undefined>;
273+
return data;
274274
},
275275
...options,
276276
},

0 commit comments

Comments
 (0)