Skip to content

Commit 29bd162

Browse files
authored
feat(openapi-react-query): Introduce queryOptions (#1858)
1 parent 6c38f6a commit 29bd162

File tree

5 files changed

+326
-57
lines changed

5 files changed

+326
-57
lines changed

.changeset/query-options.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"openapi-react-query": minor
3+
---
4+
5+
Introduce `queryOptions` that can be used as a building block to integrate with `useQueries`/`fetchQueries`/`prefetchQueries`… etc.

docs/.vitepress/en.ts

+1
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export default defineConfig({
7676
{ text: "useQuery", link: "/openapi-react-query/use-query" },
7777
{ text: "useMutation", link: "/openapi-react-query/use-mutation" },
7878
{ text: "useSuspenseQuery", link: "/openapi-react-query/use-suspense-query" },
79+
{ text: "queryOptions", link: "/openapi-react-query/query-options" },
7980
{ text: "About", link: "/openapi-react-query/about" },
8081
],
8182
},
+123
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
---
2+
title: queryOptions
3+
---
4+
# {{ $frontmatter.title }}
5+
6+
The `queryOptions` method allows you to construct type-safe [Query Options](https://tanstack.com/query/latest/docs/framework/react/guides/query-options).
7+
8+
`queryOptions` can be used together with `@tanstack/react-query` APIs that take query options, such as
9+
[useQuery](https://tanstack.com/query/latest/docs/framework/react/reference/useQuery),
10+
[useQueries](https://tanstack.com/query/latest/docs/framework/react/reference/useQueries),
11+
[usePrefetchQuery](https://tanstack.com/query/latest/docs/framework/react/reference/usePrefetchQuery) and
12+
[QueryClient.fetchQuery](https://tanstack.com/query/latest/docs/reference/QueryClient#queryclientfetchquery)
13+
among many others.
14+
15+
If you would like to use a query API that is not explicitly supported by `openapi-react-query`, this is the way to go.
16+
17+
## Examples
18+
19+
[useQuery example](use-query#example) rewritten using `queryOptions`.
20+
21+
::: code-group
22+
23+
```tsx [src/app.tsx]
24+
import { useQuery } from '@tanstack/react-query';
25+
import { $api } from "./api";
26+
27+
export const App = () => {
28+
const { data, error, isLoading } = useQuery(
29+
$api.queryOptions("get", "/users/{user_id}", {
30+
params: {
31+
path: { user_id: 5 },
32+
},
33+
}),
34+
);
35+
36+
if (!data || isLoading) return "Loading...";
37+
if (error) return `An error occured: ${error.message}`;
38+
39+
return <div>{data.firstname}</div>;
40+
};
41+
```
42+
43+
```ts [src/api.ts]
44+
import createFetchClient from "openapi-fetch";
45+
import createClient from "openapi-react-query";
46+
import type { paths } from "./my-openapi-3-schema"; // generated by openapi-typescript
47+
48+
const fetchClient = createFetchClient<paths>({
49+
baseUrl: "https://myapi.dev/v1/",
50+
});
51+
export const $api = createClient(fetchClient);
52+
```
53+
54+
:::
55+
56+
::: info Good to Know
57+
58+
Both [useQuery](use-query) and [useSuspenseQuery](use-suspense-query) use `queryOptions` under the hood.
59+
60+
:::
61+
62+
Usage with [useQueries](https://tanstack.com/query/latest/docs/framework/react/reference/useQueries).
63+
64+
::: code-group
65+
66+
```tsx [src/use-users-by-id.ts]
67+
import { useQueries } from '@tanstack/react-query';
68+
import { $api } from "./api";
69+
70+
export const useUsersById = (userIds: number[]) => (
71+
useQueries({
72+
queries: userIds.map((userId) => (
73+
$api.queryOptions("get", "/users/{user_id}", {
74+
params: {
75+
path: { user_id: userId },
76+
},
77+
})
78+
))
79+
})
80+
);
81+
```
82+
83+
```ts [src/api.ts]
84+
import createFetchClient from "openapi-fetch";
85+
import createClient from "openapi-react-query";
86+
import type { paths } from "./my-openapi-3-schema"; // generated by openapi-typescript
87+
88+
const fetchClient = createFetchClient<paths>({
89+
baseUrl: "https://myapi.dev/v1/",
90+
});
91+
export const $api = createClient(fetchClient);
92+
```
93+
94+
:::
95+
96+
## Api
97+
98+
```tsx
99+
const queryOptions = $api.queryOptions(method, path, options, queryOptions);
100+
```
101+
102+
**Arguments**
103+
104+
- `method` **(required)**
105+
- The HTTP method to use for the request.
106+
- The method is used as key. See [Query Keys](https://tanstack.com/query/latest/docs/framework/react/guides/query-keys) for more information.
107+
- `path` **(required)**
108+
- The pathname to use for the request.
109+
- Must be an available path for the given method in your schema.
110+
- The pathname is used as key. See [Query Keys](https://tanstack.com/query/latest/docs/framework/react/guides/query-keys) for more information.
111+
- `options`
112+
- The fetch options to use for the request.
113+
- Only required if the OpenApi schema requires parameters.
114+
- The options `params` are used as key. See [Query Keys](https://tanstack.com/query/latest/docs/framework/react/guides/query-keys) for more information.
115+
- `queryOptions`
116+
- Additional query options to pass through.
117+
118+
**Returns**
119+
120+
- [Query Options](https://tanstack.com/query/latest/docs/framework/react/guides/query-options)
121+
- Fully typed thus `data` and `error` will be correctly deducted.
122+
- `queryKey` is `[method, path, params]`.
123+
- `queryFn` is set to a fetcher function.

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

+68-50
Original file line numberDiff line numberDiff line change
@@ -6,39 +6,71 @@ import {
66
type UseSuspenseQueryOptions,
77
type UseSuspenseQueryResult,
88
type QueryClient,
9+
type QueryFunctionContext,
910
useMutation,
1011
useQuery,
1112
useSuspenseQuery,
1213
} from "@tanstack/react-query";
1314
import type { ClientMethod, FetchResponse, MaybeOptionalInit, Client as FetchClient } from "openapi-fetch";
1415
import type { HttpMethod, MediaType, PathsWithMethod, RequiredKeysOf } from "openapi-typescript-helpers";
1516

17+
type InitWithUnknowns<Init> = Init & { [key: string]: unknown };
18+
19+
export type QueryKey<
20+
Paths extends Record<string, Record<HttpMethod, {}>>,
21+
Method extends HttpMethod,
22+
Path extends PathsWithMethod<Paths, Method>,
23+
> = readonly [Method, Path, MaybeOptionalInit<Paths[Path], Method>];
24+
25+
export type QueryOptionsFunction<Paths extends Record<string, Record<HttpMethod, {}>>, Media extends MediaType> = <
26+
Method extends HttpMethod,
27+
Path extends PathsWithMethod<Paths, Method>,
28+
Init extends MaybeOptionalInit<Paths[Path], Method>,
29+
Response extends Required<FetchResponse<Paths[Path][Method], Init, Media>>, // note: Required is used to avoid repeating NonNullable in UseQuery types
30+
Options extends Omit<
31+
UseQueryOptions<Response["data"], Response["error"], Response["data"], QueryKey<Paths, Method, Path>>,
32+
"queryKey" | "queryFn"
33+
>,
34+
>(
35+
method: Method,
36+
path: Path,
37+
...[init, options]: RequiredKeysOf<Init> extends never
38+
? [InitWithUnknowns<Init>?, Options?]
39+
: [InitWithUnknowns<Init>, Options?]
40+
) => UseQueryOptions<Response["data"], Response["error"], Response["data"], QueryKey<Paths, Method, Path>>;
41+
1642
export type UseQueryMethod<Paths extends Record<string, Record<HttpMethod, {}>>, Media extends MediaType> = <
1743
Method extends HttpMethod,
1844
Path extends PathsWithMethod<Paths, Method>,
1945
Init extends MaybeOptionalInit<Paths[Path], Method>,
2046
Response extends Required<FetchResponse<Paths[Path][Method], Init, Media>>, // note: Required is used to avoid repeating NonNullable in UseQuery types
21-
Options extends Omit<UseQueryOptions<Response["data"], Response["error"]>, "queryKey" | "queryFn">,
47+
Options extends Omit<
48+
UseQueryOptions<Response["data"], Response["error"], Response["data"], QueryKey<Paths, Method, Path>>,
49+
"queryKey" | "queryFn"
50+
>,
2251
>(
2352
method: Method,
2453
url: Path,
2554
...[init, options, queryClient]: RequiredKeysOf<Init> extends never
26-
? [(Init & { [key: string]: unknown })?, Options?, QueryClient?]
27-
: [Init & { [key: string]: unknown }, Options?, QueryClient?]
55+
? [InitWithUnknowns<Init>?, Options?, QueryClient?]
56+
: [InitWithUnknowns<Init>, Options?, QueryClient?]
2857
) => UseQueryResult<Response["data"], Response["error"]>;
2958

3059
export type UseSuspenseQueryMethod<Paths extends Record<string, Record<HttpMethod, {}>>, Media extends MediaType> = <
3160
Method extends HttpMethod,
3261
Path extends PathsWithMethod<Paths, Method>,
3362
Init extends MaybeOptionalInit<Paths[Path], Method>,
3463
Response extends Required<FetchResponse<Paths[Path][Method], Init, Media>>, // note: Required is used to avoid repeating NonNullable in UseQuery types
35-
Options extends Omit<UseSuspenseQueryOptions<Response["data"], Response["error"]>, "queryKey" | "queryFn">,
64+
Options extends Omit<
65+
UseSuspenseQueryOptions<Response["data"], Response["error"], Response["data"], QueryKey<Paths, Method, Path>>,
66+
"queryKey" | "queryFn"
67+
>,
3668
>(
3769
method: Method,
3870
url: Path,
3971
...[init, options, queryClient]: RequiredKeysOf<Init> extends never
40-
? [(Init & { [key: string]: unknown })?, Options?, QueryClient?]
41-
: [Init & { [key: string]: unknown }, Options?, QueryClient?]
72+
? [InitWithUnknowns<Init>?, Options?, QueryClient?]
73+
: [InitWithUnknowns<Init>, Options?, QueryClient?]
4274
) => UseSuspenseQueryResult<Response["data"], Response["error"]>;
4375

4476
export type UseMutationMethod<Paths extends Record<string, Record<HttpMethod, {}>>, Media extends MediaType> = <
@@ -55,62 +87,49 @@ export type UseMutationMethod<Paths extends Record<string, Record<HttpMethod, {}
5587
) => UseMutationResult<Response["data"], Response["error"], Init>;
5688

5789
export interface OpenapiQueryClient<Paths extends {}, Media extends MediaType = MediaType> {
90+
queryOptions: QueryOptionsFunction<Paths, Media>;
5891
useQuery: UseQueryMethod<Paths, Media>;
5992
useSuspenseQuery: UseSuspenseQueryMethod<Paths, Media>;
6093
useMutation: UseMutationMethod<Paths, Media>;
6194
}
6295

63-
// TODO: Move the client[method]() fn outside for reusability
6496
// TODO: Add the ability to bring queryClient as argument
6597
export default function createClient<Paths extends {}, Media extends MediaType = MediaType>(
6698
client: FetchClient<Paths, Media>,
6799
): OpenapiQueryClient<Paths, Media> {
100+
const queryFn = async <Method extends HttpMethod, Path extends PathsWithMethod<Paths, Method>>({
101+
queryKey: [method, path, init],
102+
signal,
103+
}: QueryFunctionContext<QueryKey<Paths, Method, Path>>) => {
104+
const mth = method.toUpperCase() as Uppercase<typeof method>;
105+
const fn = client[mth] as ClientMethod<Paths, typeof method, Media>;
106+
const { data, error } = await fn(path, { signal, ...(init as any) }); // TODO: find a way to avoid as any
107+
if (error || !data) {
108+
throw error;
109+
}
110+
return data;
111+
};
112+
113+
const queryOptions: QueryOptionsFunction<Paths, Media> = (method, path, ...[init, options]) => ({
114+
queryKey: [method, path, init as InitWithUnknowns<typeof init>] as const,
115+
queryFn,
116+
...options,
117+
});
118+
68119
return {
69-
useQuery: (method, path, ...[init, options, queryClient]) => {
70-
return useQuery(
71-
{
72-
queryKey: [method, path, init],
73-
queryFn: async ({ signal }) => {
74-
const mth = method.toUpperCase() as keyof typeof client;
75-
const fn = client[mth] as ClientMethod<Paths, typeof method, Media>;
76-
const { data, error } = await fn(path, { signal, ...(init as any) }); // TODO: find a way to avoid as any
77-
if (error || !data) {
78-
throw error;
79-
}
80-
return data;
81-
},
82-
...options,
83-
},
84-
queryClient,
85-
);
86-
},
87-
useSuspenseQuery: (method, path, ...[init, options, queryClient]) => {
88-
return useSuspenseQuery(
89-
{
90-
queryKey: [method, path, init],
91-
queryFn: async ({ signal }) => {
92-
const mth = method.toUpperCase() as keyof typeof client;
93-
const fn = client[mth] as ClientMethod<Paths, typeof method, Media>;
94-
const { data, error } = await fn(path, { signal, ...(init as any) }); // TODO: find a way to avoid as any
95-
if (error || !data) {
96-
throw error;
97-
}
98-
return data;
99-
},
100-
...options,
101-
},
102-
queryClient,
103-
);
104-
},
105-
useMutation: (method, path, options, queryClient) => {
106-
return useMutation(
120+
queryOptions,
121+
useQuery: (method, path, ...[init, options, queryClient]) =>
122+
useQuery(queryOptions(method, path, init as InitWithUnknowns<typeof init>, options), queryClient),
123+
useSuspenseQuery: (method, path, ...[init, options, queryClient]) =>
124+
useSuspenseQuery(queryOptions(method, path, init as InitWithUnknowns<typeof init>, options), queryClient),
125+
useMutation: (method, path, options, queryClient) =>
126+
useMutation(
107127
{
108128
mutationKey: [method, path],
109129
mutationFn: async (init) => {
110-
// TODO: Put in external fn for reusability
111-
const mth = method.toUpperCase() as keyof typeof client;
130+
const mth = method.toUpperCase() as Uppercase<typeof method>;
112131
const fn = client[mth] as ClientMethod<Paths, typeof method, Media>;
113-
const { data, error } = await fn(path, init as any); // TODO: find a way to avoid as any
132+
const { data, error } = await fn(path, init as InitWithUnknowns<typeof init>);
114133
if (error || !data) {
115134
throw error;
116135
}
@@ -119,7 +138,6 @@ export default function createClient<Paths extends {}, Media extends MediaType =
119138
...options,
120139
},
121140
queryClient,
122-
);
123-
},
141+
),
124142
};
125143
}

0 commit comments

Comments
 (0)