Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tough-coins-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"openapi-fetch": patch
---

reject excess request body and params properties
36 changes: 33 additions & 3 deletions packages/openapi-fetch/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,36 @@ export interface DefaultParamsOption {
};
}

type DeepExact<Actual, Shape> = Actual extends Shape
? Actual extends readonly (infer ActualItem)[]
? Shape extends readonly (infer ShapeItem)[]
? readonly DeepExact<ActualItem, ShapeItem>[]
: Actual
: Actual extends object
? Shape extends object
? { [K in keyof Actual]: K extends keyof Shape ? DeepExact<Actual[K], Shape[K]> : never }
: Actual
: Actual
: never;

type ExactOptionProperty<Init extends object, Key extends "params" | "body", Shape> = Key extends keyof Init
? {} extends Pick<Init, Key>
? { [K in Key]?: DeepExact<Exclude<Init[K], undefined>, Shape> | Extract<Init[K], undefined> }
: { [K in Key]: DeepExact<Exclude<Init[K], undefined>, Shape> | Extract<Init[K], undefined> }
: {};

type ExactParamsAndBody<Init, Operation> = Init extends undefined
? Init
: Init extends object
? Omit<Init, "params" | "body"> &
ExactOptionProperty<
Init,
"params",
ParamsOption<Operation> extends { params?: infer Params } ? Params : never
> &
ExactOptionProperty<Init, "body", RequestBodyOption<Operation> extends { body?: infer Body } ? Body : never>
: Init;

export type ParamsOption<T> = T extends {
parameters: any;
}
Expand Down Expand Up @@ -208,7 +238,7 @@ export type ClientMethod<
Media extends MediaType,
> = <Path extends PathsWithMethod<Paths, Method>, Init extends MaybeOptionalInit<Paths[Path], Method>>(
url: Path,
...init: InitParam<Init>
...init: InitParam<ExactParamsAndBody<Init, FilterKeys<Paths[Path], Method>>>
) => Promise<FetchResponse<Paths[Path][Method], Init, Media>>;

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

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

Expand Down
63 changes: 63 additions & 0 deletions packages/openapi-fetch/test/common/params.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { test } from "vitest";
import createClient from "../../src/index.js";
import type { paths } from "./schemas/common.js";

test("GET rejects undefined query params", () => {
const client = createClient<paths>();

client.GET("/query-params", {
params: {
query: {
array: [],
// Regression test: extra query params should be rejected.
// @ts-expect-error
undefined_property: [],
},
},
});
});

test("client.request rejects undefined query params", () => {
const client = createClient<paths>();

client.request("get", "/query-params", {
params: {
query: {
array: [],
// Regression test: extra query params should be rejected via client.request().
// @ts-expect-error
undefined_property: [],
},
},
});
});

test("GET rejects undefined path params", () => {
const client = createClient<paths>();

client.GET("/resources/{id}", {
params: {
path: {
id: 123,
// Regression test: extra path params should be rejected.
// @ts-expect-error
undefined_property: 456,
},
},
});
});

test("client.request rejects undefined path params", () => {
const client = createClient<paths>();

client.request("get", "/resources/{id}", {
params: {
path: {
id: 123,
// Regression test: extra path params should be rejected via client.request().
// @ts-expect-error
undefined_property: 456,
},
},
});
});
4 changes: 4 additions & 0 deletions packages/openapi-fetch/test/common/schemas/common.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,8 @@ export interface paths {
number?: number;
boolean?: boolean;
array?: string[];
second_array?: string[];
third_array?: string[];
object?: {
foo: string;
bar: string;
Expand All @@ -688,6 +690,8 @@ export interface paths {
number?: number;
boolean?: boolean;
array?: string[];
second_array?: string[];
third_array?: string[];
object?: {
foo: string;
bar: string;
Expand Down
12 changes: 12 additions & 0 deletions packages/openapi-fetch/test/common/schemas/common.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,18 @@ paths:
type: array
items:
type: string
- in: query
name: second_array
schema:
type: array
items:
type: string
- in: query
name: third_array
schema:
type: array
items:
type: string
- in: query
name: object
schema:
Expand Down
18 changes: 18 additions & 0 deletions packages/openapi-fetch/test/http-methods/post.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { test } from "vitest";
import createClient from "../../src/index.js";
import type { paths } from "./schemas/post.js";

test("POST rejects undefined request body properties", () => {
const client = createClient<paths>();

client.POST("/posts", {
body: {
title: "My Post",
body: "Post body",
publish_date: new Date("2024-06-06T12:00:00Z").getTime(),
// Regression test for #1769: extra body fields must still error when required fields are present.
// @ts-expect-error
undefined_property: true,
},
});
});
18 changes: 18 additions & 0 deletions packages/openapi-fetch/test/http-methods/request.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { test } from "vitest";
import createClient from "../../src/index.js";
import type { paths } from "./schemas/post.js";

test("client.request rejects undefined request body properties", () => {
const client = createClient<paths>();

client.request("post", "/posts", {
body: {
title: "My Post",
body: "Post body",
publish_date: new Date("2024-06-06T12:00:00Z").getTime(),
// Regression test for #1769 across client.request().
// @ts-expect-error
undefined_property: true,
},
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { test } from "vitest";
import { createPathBasedClient } from "../../src/index.js";
import type { paths } from "../http-methods/schemas/post.js";

test("path-based client rejects undefined request body properties", () => {
const client = createPathBasedClient<paths>();

client["/posts"].POST({
body: {
title: "My Post",
body: "Post body",
publish_date: new Date("2024-06-06T12:00:00Z").getTime(),
// Regression test for #1769 across path-based clients.
// @ts-expect-error
undefined_property: true,
},
});
});
4 changes: 2 additions & 2 deletions packages/openapi-react-query/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,12 +265,12 @@ export default function createClient<Paths extends {}, Media extends MediaType =
mutationFn: async (init) => {
const mth = method.toUpperCase() as Uppercase<typeof method>;
const fn = client[mth] as ClientMethod<Paths, typeof method, Media>;
const { data, error } = await fn(path, init as InitWithUnknowns<typeof init>);
const { data, error } = await fn(path, init as any); // TODO: find a way to avoid as any
if (error) {
throw error;
}

return data as Exclude<typeof data, undefined>;
return data;
},
...options,
},
Expand Down
Loading