diff --git a/.changeset/exact-query-params.md b/.changeset/exact-query-params.md new file mode 100644 index 000000000..4548c1fc9 --- /dev/null +++ b/.changeset/exact-query-params.md @@ -0,0 +1,5 @@ +--- +"openapi-react-query": patch +--- + +fix(openapi-react-query): reject query and path parameters the operation does not declare diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts index fb9683164..73ebd0dfa 100644 --- a/packages/openapi-react-query/src/index.ts +++ b/packages/openapi-react-query/src/index.ts @@ -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"; @@ -31,6 +32,20 @@ type InferSelectReturnType = TSelect extends (data: TData) => in type InitWithUnknowns = 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>, + Method extends HttpMethod, + Path extends PathsWithMethod, + ParseAsOpt, +> = InitWithUnknowns & { parseAs?: ParseAsOpt }>; + +// `never` is the no-`parseAs`-passed case, which must not satisfy ParseAsResponse's check. +type ParseAsInit = [ParseAsOpt] extends [never] ? unknown : { parseAs: ParseAsOpt }; + export type QueryKey< Paths extends Record>, Method extends HttpMethod, @@ -41,8 +56,7 @@ export type QueryKey< export type QueryOptionsFunction>, Media extends MediaType> = < Method extends HttpMethod, Path extends PathsWithMethod, - Init extends MaybeOptionalInit, - Response extends Required>, // note: Required is used to avoid repeating NonNullable in UseQuery types + Response extends Required, Media>>, // note: Required is used to avoid repeating NonNullable in UseQuery types Options extends Omit< UseQueryOptions< Response["data"], @@ -52,12 +66,13 @@ export type QueryOptionsFunction, "queryKey" | "queryFn" >, + ParseAsOpt extends ParseAs = never, >( method: Method, path: Path, - ...[init, options]: RequiredKeysOf extends never - ? [InitWithUnknowns?, Options?] - : [InitWithUnknowns, Options?] + ...[init, options]: RequiredKeysOf> extends never + ? [ExactInit?, Options?] + : [ExactInit, Options?] ) => NoInfer< Omit< UseQueryOptions< @@ -84,8 +99,7 @@ export type QueryOptionsFunction>, Media extends MediaType> = < Method extends HttpMethod, Path extends PathsWithMethod, - Init extends MaybeOptionalInit, - Response extends Required>, // note: Required is used to avoid repeating NonNullable in UseQuery types + Response extends Required, Media>>, // note: Required is used to avoid repeating NonNullable in UseQuery types Options extends Omit< UseQueryOptions< Response["data"], @@ -95,19 +109,19 @@ export type UseQueryMethod>, >, "queryKey" | "queryFn" >, + ParseAsOpt extends ParseAs = never, >( method: Method, url: Path, - ...[init, options, queryClient]: RequiredKeysOf extends never - ? [InitWithUnknowns?, Options?, QueryClient?] - : [InitWithUnknowns, Options?, QueryClient?] + ...[init, options, queryClient]: RequiredKeysOf> extends never + ? [ExactInit?, Options?, QueryClient?] + : [ExactInit, Options?, QueryClient?] ) => UseQueryResult, Response["error"]>; export type UseInfiniteQueryMethod>, Media extends MediaType> = < Method extends HttpMethod, Path extends PathsWithMethod, - Init extends MaybeOptionalInit, - Response extends Required>, + Response extends Required, Media>>, Options extends Omit< UseInfiniteQueryOptions< Response["data"], @@ -120,10 +134,11 @@ export type UseInfiniteQueryMethod & { pageParamName?: string; }, + ParseAsOpt extends ParseAs = never, >( method: Method, url: Path, - init: InitWithUnknowns, + init: ExactInit, options: Options, queryClient?: QueryClient, ) => UseInfiniteQueryResult< @@ -134,8 +149,7 @@ export type UseInfiniteQueryMethod>, Media extends MediaType> = < Method extends HttpMethod, Path extends PathsWithMethod, - Init extends MaybeOptionalInit, - Response extends Required>, // note: Required is used to avoid repeating NonNullable in UseQuery types + Response extends Required, Media>>, // note: Required is used to avoid repeating NonNullable in UseQuery types Options extends Omit< UseSuspenseQueryOptions< Response["data"], @@ -145,12 +159,13 @@ export type UseSuspenseQueryMethod, "queryKey" | "queryFn" >, + ParseAsOpt extends ParseAs = never, >( method: Method, url: Path, - ...[init, options, queryClient]: RequiredKeysOf extends never - ? [InitWithUnknowns?, Options?, QueryClient?] - : [InitWithUnknowns, Options?, QueryClient?] + ...[init, options, queryClient]: RequiredKeysOf> extends never + ? [ExactInit?, Options?, QueryClient?] + : [ExactInit, Options?, QueryClient?] ) => UseSuspenseQueryResult, Response["error"]>; export type UseMutationMethod>, Media extends MediaType> = < @@ -228,7 +243,7 @@ export default function createClient, 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); return useInfiniteQuery( { queryKey, diff --git a/packages/openapi-react-query/test/index.test.tsx b/packages/openapi-react-query/test/index.test.tsx index 78715e643..af1d11949 100644 --- a/packages/openapi-react-query/test/index.test.tsx +++ b/packages/openapi-react-query/test/index.test.tsx @@ -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({ baseUrl }); + const client = createClient(fetchClient); + + const blob = client.queryOptions("get", "/string-array", { parseAs: "blob" }); + expectTypeOf>>().toEqualTypeOf(); + + const json = client.queryOptions("get", "/string-array"); + expectTypeOf>>().toEqualTypeOf(); }); it("correctly infers return type from query key", async () => {