Skip to content
Open
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/exact-query-params.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"openapi-react-query": patch
---

fix(openapi-react-query): reject query and path parameters the operation does not declare
53 changes: 34 additions & 19 deletions packages/openapi-react-query/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
Client as FetchClient,
FetchResponse,
MaybeOptionalInit,
ParseAs,
} from "openapi-fetch";
import type { HttpMethod, MediaType, PathsWithMethod, RequiredKeysOf } from "openapi-typescript-helpers";

Expand All @@ -31,6 +32,20 @@ type InferSelectReturnType<TData, TSelect> = TSelect extends (data: TData) => in

type InitWithUnknowns<Init> = Init & { [key: string]: unknown };

// The init argument is contextually typed by the operation's own init rather than by whatever the
// caller passed, so an undeclared query or path parameter is reported as an excess property.
// `parseAs` is the only part of init the response type depends on, so it is inferred by itself.
// Inferring the whole init is what loses the check.
type ExactInit<
Paths extends Record<string, Record<HttpMethod, {}>>,
Method extends HttpMethod,
Path extends PathsWithMethod<Paths, Method>,
ParseAsOpt,
> = InitWithUnknowns<MaybeOptionalInit<Paths[Path], Method> & { parseAs?: ParseAsOpt }>;

// `never` is the no-`parseAs`-passed case, which must not satisfy ParseAsResponse's check.
type ParseAsInit<ParseAsOpt> = [ParseAsOpt] extends [never] ? unknown : { parseAs: ParseAsOpt };

export type QueryKey<
Paths extends Record<string, Record<HttpMethod, {}>>,
Method extends HttpMethod,
Expand All @@ -41,8 +56,7 @@ export type QueryKey<
export type QueryOptionsFunction<Paths extends Record<string, Record<HttpMethod, {}>>, Media extends MediaType> = <
Method extends HttpMethod,
Path extends PathsWithMethod<Paths, Method>,
Init extends MaybeOptionalInit<Paths[Path], Method>,
Response extends Required<FetchResponse<Paths[Path][Method], Init, Media>>, // note: Required is used to avoid repeating NonNullable in UseQuery types
Response extends Required<FetchResponse<Paths[Path][Method], ParseAsInit<ParseAsOpt>, Media>>, // note: Required is used to avoid repeating NonNullable in UseQuery types
Options extends Omit<
UseQueryOptions<
Response["data"],
Expand All @@ -52,12 +66,13 @@ export type QueryOptionsFunction<Paths extends Record<string, Record<HttpMethod,
>,
"queryKey" | "queryFn"
>,
ParseAsOpt extends ParseAs = never,
>(
method: Method,
path: Path,
...[init, options]: RequiredKeysOf<Init> extends never
? [InitWithUnknowns<Init>?, Options?]
: [InitWithUnknowns<Init>, Options?]
...[init, options]: RequiredKeysOf<MaybeOptionalInit<Paths[Path], Method>> extends never
? [ExactInit<Paths, Method, Path, ParseAsOpt>?, Options?]
: [ExactInit<Paths, Method, Path, ParseAsOpt>, Options?]
) => NoInfer<
Omit<
UseQueryOptions<
Expand All @@ -84,8 +99,7 @@ export type QueryOptionsFunction<Paths extends Record<string, Record<HttpMethod,
export type UseQueryMethod<Paths extends Record<string, Record<HttpMethod, {}>>, Media extends MediaType> = <
Method extends HttpMethod,
Path extends PathsWithMethod<Paths, Method>,
Init extends MaybeOptionalInit<Paths[Path], Method>,
Response extends Required<FetchResponse<Paths[Path][Method], Init, Media>>, // note: Required is used to avoid repeating NonNullable in UseQuery types
Response extends Required<FetchResponse<Paths[Path][Method], ParseAsInit<ParseAsOpt>, Media>>, // note: Required is used to avoid repeating NonNullable in UseQuery types
Options extends Omit<
UseQueryOptions<
Response["data"],
Expand All @@ -95,19 +109,19 @@ export type UseQueryMethod<Paths extends Record<string, Record<HttpMethod, {}>>,
>,
"queryKey" | "queryFn"
>,
ParseAsOpt extends ParseAs = never,
>(
method: Method,
url: Path,
...[init, options, queryClient]: RequiredKeysOf<Init> extends never
? [InitWithUnknowns<Init>?, Options?, QueryClient?]
: [InitWithUnknowns<Init>, Options?, QueryClient?]
...[init, options, queryClient]: RequiredKeysOf<MaybeOptionalInit<Paths[Path], Method>> extends never
? [ExactInit<Paths, Method, Path, ParseAsOpt>?, Options?, QueryClient?]
: [ExactInit<Paths, Method, Path, ParseAsOpt>, Options?, QueryClient?]
) => UseQueryResult<InferSelectReturnType<Response["data"], Options["select"]>, Response["error"]>;

export type UseInfiniteQueryMethod<Paths extends Record<string, Record<HttpMethod, {}>>, Media extends MediaType> = <
Method extends HttpMethod,
Path extends PathsWithMethod<Paths, Method>,
Init extends MaybeOptionalInit<Paths[Path], Method>,
Response extends Required<FetchResponse<Paths[Path][Method], Init, Media>>,
Response extends Required<FetchResponse<Paths[Path][Method], ParseAsInit<ParseAsOpt>, Media>>,
Options extends Omit<
UseInfiniteQueryOptions<
Response["data"],
Expand All @@ -120,10 +134,11 @@ export type UseInfiniteQueryMethod<Paths extends Record<string, Record<HttpMetho
> & {
pageParamName?: string;
},
ParseAsOpt extends ParseAs = never,
>(
method: Method,
url: Path,
init: InitWithUnknowns<Init>,
init: ExactInit<Paths, Method, Path, ParseAsOpt>,
options: Options,
queryClient?: QueryClient,
) => UseInfiniteQueryResult<
Expand All @@ -134,8 +149,7 @@ export type UseInfiniteQueryMethod<Paths extends Record<string, Record<HttpMetho
export type UseSuspenseQueryMethod<Paths extends Record<string, Record<HttpMethod, {}>>, Media extends MediaType> = <
Method extends HttpMethod,
Path extends PathsWithMethod<Paths, Method>,
Init extends MaybeOptionalInit<Paths[Path], Method>,
Response extends Required<FetchResponse<Paths[Path][Method], Init, Media>>, // note: Required is used to avoid repeating NonNullable in UseQuery types
Response extends Required<FetchResponse<Paths[Path][Method], ParseAsInit<ParseAsOpt>, Media>>, // note: Required is used to avoid repeating NonNullable in UseQuery types
Options extends Omit<
UseSuspenseQueryOptions<
Response["data"],
Expand All @@ -145,12 +159,13 @@ export type UseSuspenseQueryMethod<Paths extends Record<string, Record<HttpMetho
>,
"queryKey" | "queryFn"
>,
ParseAsOpt extends ParseAs = never,
>(
method: Method,
url: Path,
...[init, options, queryClient]: RequiredKeysOf<Init> extends never
? [InitWithUnknowns<Init>?, Options?, QueryClient?]
: [InitWithUnknowns<Init>, Options?, QueryClient?]
...[init, options, queryClient]: RequiredKeysOf<MaybeOptionalInit<Paths[Path], Method>> extends never
? [ExactInit<Paths, Method, Path, ParseAsOpt>?, Options?, QueryClient?]
: [ExactInit<Paths, Method, Path, ParseAsOpt>, Options?, QueryClient?]
) => UseSuspenseQueryResult<InferSelectReturnType<Response["data"], Options["select"]>, Response["error"]>;

export type UseMutationMethod<Paths extends Record<string, Record<HttpMethod, {}>>, Media extends MediaType> = <
Expand Down Expand Up @@ -228,7 +243,7 @@ export default function createClient<Paths extends {}, Media extends MediaType =
useSuspenseQuery(queryOptions(method, path, init as InitWithUnknowns<typeof init>, options), queryClient),
useInfiniteQuery: (method, path, init, options, queryClient) => {
const { pageParamName = "cursor", ...restOptions } = options;
const { queryKey } = queryOptions(method, path, init);
const { queryKey } = queryOptions(method, path, init as InitWithUnknowns<typeof init>);
return useInfiniteQuery(
{
queryKey,
Expand Down
15 changes: 15 additions & 0 deletions packages/openapi-react-query/test/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,21 @@ describe("client", () => {
client.queryOptions("get", "/string-arrayX");
// @ts-expect-error: Missing 'post_id' param.
client.queryOptions("get", "/blogposts/{post_id}", {});
// @ts-expect-error: Undeclared query param.
client.queryOptions("get", "/query-params", { params: { query: { string: "a", undeclared: 1 } } });
// @ts-expect-error: Undeclared path param.
client.queryOptions("get", "/blogposts/{post_id}", { params: { path: { post_id: "1", undeclared: "2" } } });
});

it("infers the response type from parseAs", async () => {
const fetchClient = createFetchClient<paths>({ baseUrl });
const client = createClient(fetchClient);

const blob = client.queryOptions("get", "/string-array", { parseAs: "blob" });
expectTypeOf<Awaited<ReturnType<typeof blob.queryFn>>>().toEqualTypeOf<Blob>();

const json = client.queryOptions("get", "/string-array");
expectTypeOf<Awaited<ReturnType<typeof json.queryFn>>>().toEqualTypeOf<string[]>();
});

it("correctly infers return type from query key", async () => {
Expand Down
Loading