diff --git a/.changeset/query-options.md b/.changeset/query-options.md
new file mode 100644
index 000000000..ba194f6bc
--- /dev/null
+++ b/.changeset/query-options.md
@@ -0,0 +1,5 @@
+---
+"openapi-react-query": minor
+---
+
+Introduce `queryOptions` that can be used as a building block to integrate with `useQueries`/`fetchQueries`/`prefetchQueries`… etc.
diff --git a/docs/.vitepress/en.ts b/docs/.vitepress/en.ts
index fd314c958..f87633c8b 100644
--- a/docs/.vitepress/en.ts
+++ b/docs/.vitepress/en.ts
@@ -76,6 +76,7 @@ export default defineConfig({
{ text: "useQuery", link: "/openapi-react-query/use-query" },
{ text: "useMutation", link: "/openapi-react-query/use-mutation" },
{ text: "useSuspenseQuery", link: "/openapi-react-query/use-suspense-query" },
+ { text: "queryOptions", link: "/openapi-react-query/query-options" },
{ text: "About", link: "/openapi-react-query/about" },
],
},
diff --git a/docs/openapi-react-query/query-options.md b/docs/openapi-react-query/query-options.md
new file mode 100644
index 000000000..f98c6fa26
--- /dev/null
+++ b/docs/openapi-react-query/query-options.md
@@ -0,0 +1,123 @@
+---
+title: queryOptions
+---
+# {{ $frontmatter.title }}
+
+The `queryOptions` method allows you to construct type-safe [Query Options](https://tanstack.com/query/latest/docs/framework/react/guides/query-options).
+
+`queryOptions` can be used together with `@tanstack/react-query` APIs that take query options, such as
+[useQuery](https://tanstack.com/query/latest/docs/framework/react/reference/useQuery),
+[useQueries](https://tanstack.com/query/latest/docs/framework/react/reference/useQueries),
+[usePrefetchQuery](https://tanstack.com/query/latest/docs/framework/react/reference/usePrefetchQuery) and
+[QueryClient.fetchQuery](https://tanstack.com/query/latest/docs/reference/QueryClient#queryclientfetchquery)
+among many others.
+
+If you would like to use a query API that is not explicitly supported by `openapi-react-query`, this is the way to go.
+
+## Examples
+
+[useQuery example](use-query#example) rewritten using `queryOptions`.
+
+::: code-group
+
+```tsx [src/app.tsx]
+import { useQuery } from '@tanstack/react-query';
+import { $api } from "./api";
+
+export const App = () => {
+ const { data, error, isLoading } = useQuery(
+ $api.queryOptions("get", "/users/{user_id}", {
+ params: {
+ path: { user_id: 5 },
+ },
+ }),
+ );
+
+ if (!data || isLoading) return "Loading...";
+ if (error) return `An error occured: ${error.message}`;
+
+ return
{data.firstname}
;
+};
+```
+
+```ts [src/api.ts]
+import createFetchClient from "openapi-fetch";
+import createClient from "openapi-react-query";
+import type { paths } from "./my-openapi-3-schema"; // generated by openapi-typescript
+
+const fetchClient = createFetchClient({
+ baseUrl: "https://myapi.dev/v1/",
+});
+export const $api = createClient(fetchClient);
+```
+
+:::
+
+::: info Good to Know
+
+Both [useQuery](use-query) and [useSuspenseQuery](use-suspense-query) use `queryOptions` under the hood.
+
+:::
+
+Usage with [useQueries](https://tanstack.com/query/latest/docs/framework/react/reference/useQueries).
+
+::: code-group
+
+```tsx [src/use-users-by-id.ts]
+import { useQueries } from '@tanstack/react-query';
+import { $api } from "./api";
+
+export const useUsersById = (userIds: number[]) => (
+ useQueries({
+ queries: userIds.map((userId) => (
+ $api.queryOptions("get", "/users/{user_id}", {
+ params: {
+ path: { user_id: userId },
+ },
+ })
+ ))
+ })
+);
+```
+
+```ts [src/api.ts]
+import createFetchClient from "openapi-fetch";
+import createClient from "openapi-react-query";
+import type { paths } from "./my-openapi-3-schema"; // generated by openapi-typescript
+
+const fetchClient = createFetchClient({
+ baseUrl: "https://myapi.dev/v1/",
+});
+export const $api = createClient(fetchClient);
+```
+
+:::
+
+## Api
+
+```tsx
+const queryOptions = $api.queryOptions(method, path, options, queryOptions);
+```
+
+**Arguments**
+
+- `method` **(required)**
+ - The HTTP method to use for the request.
+ - The method is used as key. See [Query Keys](https://tanstack.com/query/latest/docs/framework/react/guides/query-keys) for more information.
+- `path` **(required)**
+ - The pathname to use for the request.
+ - Must be an available path for the given method in your schema.
+ - The pathname is used as key. See [Query Keys](https://tanstack.com/query/latest/docs/framework/react/guides/query-keys) for more information.
+- `options`
+ - The fetch options to use for the request.
+ - Only required if the OpenApi schema requires parameters.
+ - The options `params` are used as key. See [Query Keys](https://tanstack.com/query/latest/docs/framework/react/guides/query-keys) for more information.
+- `queryOptions`
+ - Additional query options to pass through.
+
+**Returns**
+
+- [Query Options](https://tanstack.com/query/latest/docs/framework/react/guides/query-options)
+ - Fully typed thus `data` and `error` will be correctly deducted.
+ - `queryKey` is `[method, path, params]`.
+ - `queryFn` is set to a fetcher function.
diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts
index 95dde2d9d..95b965472 100644
--- a/packages/openapi-react-query/src/index.ts
+++ b/packages/openapi-react-query/src/index.ts
@@ -6,6 +6,7 @@ import {
type UseSuspenseQueryOptions,
type UseSuspenseQueryResult,
type QueryClient,
+ type QueryFunctionContext,
useMutation,
useQuery,
useSuspenseQuery,
@@ -13,18 +14,46 @@ import {
import type { ClientMethod, FetchResponse, MaybeOptionalInit, Client as FetchClient } from "openapi-fetch";
import type { HttpMethod, MediaType, PathsWithMethod, RequiredKeysOf } from "openapi-typescript-helpers";
+type InitWithUnknowns = Init & { [key: string]: unknown };
+
+export type QueryKey<
+ Paths extends Record>,
+ Method extends HttpMethod,
+ Path extends PathsWithMethod,
+> = readonly [Method, Path, MaybeOptionalInit];
+
+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
+ Options extends Omit<
+ UseQueryOptions>,
+ "queryKey" | "queryFn"
+ >,
+>(
+ method: Method,
+ path: Path,
+ ...[init, options]: RequiredKeysOf extends never
+ ? [InitWithUnknowns?, Options?]
+ : [InitWithUnknowns, Options?]
+) => UseQueryOptions>;
+
export type UseQueryMethod>, 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
- Options extends Omit, "queryKey" | "queryFn">,
+ Options extends Omit<
+ UseQueryOptions>,
+ "queryKey" | "queryFn"
+ >,
>(
method: Method,
url: Path,
...[init, options, queryClient]: RequiredKeysOf extends never
- ? [(Init & { [key: string]: unknown })?, Options?, QueryClient?]
- : [Init & { [key: string]: unknown }, Options?, QueryClient?]
+ ? [InitWithUnknowns?, Options?, QueryClient?]
+ : [InitWithUnknowns, Options?, QueryClient?]
) => UseQueryResult;
export type UseSuspenseQueryMethod>, Media extends MediaType> = <
@@ -32,13 +61,16 @@ export type UseSuspenseQueryMethod,
Init extends MaybeOptionalInit,
Response extends Required>, // note: Required is used to avoid repeating NonNullable in UseQuery types
- Options extends Omit, "queryKey" | "queryFn">,
+ Options extends Omit<
+ UseSuspenseQueryOptions>,
+ "queryKey" | "queryFn"
+ >,
>(
method: Method,
url: Path,
...[init, options, queryClient]: RequiredKeysOf extends never
- ? [(Init & { [key: string]: unknown })?, Options?, QueryClient?]
- : [Init & { [key: string]: unknown }, Options?, QueryClient?]
+ ? [InitWithUnknowns?, Options?, QueryClient?]
+ : [InitWithUnknowns, Options?, QueryClient?]
) => UseSuspenseQueryResult;
export type UseMutationMethod>, Media extends MediaType> = <
@@ -55,62 +87,49 @@ export type UseMutationMethod UseMutationResult;
export interface OpenapiQueryClient {
+ queryOptions: QueryOptionsFunction;
useQuery: UseQueryMethod;
useSuspenseQuery: UseSuspenseQueryMethod;
useMutation: UseMutationMethod;
}
-// TODO: Move the client[method]() fn outside for reusability
// TODO: Add the ability to bring queryClient as argument
export default function createClient(
client: FetchClient,
): OpenapiQueryClient {
+ const queryFn = async >({
+ queryKey: [method, path, init],
+ signal,
+ }: QueryFunctionContext>) => {
+ const mth = method.toUpperCase() as Uppercase;
+ const fn = client[mth] as ClientMethod;
+ const { data, error } = await fn(path, { signal, ...(init as any) }); // TODO: find a way to avoid as any
+ if (error || !data) {
+ throw error;
+ }
+ return data;
+ };
+
+ const queryOptions: QueryOptionsFunction = (method, path, ...[init, options]) => ({
+ queryKey: [method, path, init as InitWithUnknowns] as const,
+ queryFn,
+ ...options,
+ });
+
return {
- useQuery: (method, path, ...[init, options, queryClient]) => {
- return useQuery(
- {
- queryKey: [method, path, init],
- queryFn: async ({ signal }) => {
- const mth = method.toUpperCase() as keyof typeof client;
- const fn = client[mth] as ClientMethod;
- const { data, error } = await fn(path, { signal, ...(init as any) }); // TODO: find a way to avoid as any
- if (error || !data) {
- throw error;
- }
- return data;
- },
- ...options,
- },
- queryClient,
- );
- },
- useSuspenseQuery: (method, path, ...[init, options, queryClient]) => {
- return useSuspenseQuery(
- {
- queryKey: [method, path, init],
- queryFn: async ({ signal }) => {
- const mth = method.toUpperCase() as keyof typeof client;
- const fn = client[mth] as ClientMethod;
- const { data, error } = await fn(path, { signal, ...(init as any) }); // TODO: find a way to avoid as any
- if (error || !data) {
- throw error;
- }
- return data;
- },
- ...options,
- },
- queryClient,
- );
- },
- useMutation: (method, path, options, queryClient) => {
- return useMutation(
+ queryOptions,
+ useQuery: (method, path, ...[init, options, queryClient]) =>
+ useQuery(queryOptions(method, path, init as InitWithUnknowns, options), queryClient),
+ useSuspenseQuery: (method, path, ...[init, options, queryClient]) =>
+ useSuspenseQuery(queryOptions(method, path, init as InitWithUnknowns, options), queryClient),
+ useMutation: (method, path, options, queryClient) =>
+ useMutation(
{
mutationKey: [method, path],
mutationFn: async (init) => {
- // TODO: Put in external fn for reusability
- const mth = method.toUpperCase() as keyof typeof client;
+ const mth = method.toUpperCase() as Uppercase;
const fn = client[mth] as ClientMethod;
- const { data, error } = await fn(path, init as any); // TODO: find a way to avoid as any
+ const { data, error } = await fn(path, init as InitWithUnknowns);
if (error || !data) {
throw error;
}
@@ -119,7 +138,6 @@ export default function createClient (
{children}
);
+const fetchInfinite = async () => {
+ await new Promise(() => {});
+ return Response.error();
+};
+
beforeAll(() => {
server.listen({
onUnhandledRequest: (request) => {
@@ -39,13 +44,117 @@ describe("client", () => {
it("generates all proper functions", () => {
const fetchClient = createFetchClient({ baseUrl });
const client = createClient(fetchClient);
+ expect(client).toHaveProperty("queryOptions");
expect(client).toHaveProperty("useQuery");
expect(client).toHaveProperty("useSuspenseQuery");
expect(client).toHaveProperty("useMutation");
});
+ describe("queryOptions", () => {
+ it("has correct parameter types", async () => {
+ const fetchClient = createFetchClient({ baseUrl });
+ const client = createClient(fetchClient);
+
+ client.queryOptions("get", "/string-array");
+ // @ts-expect-error: Wrong method.
+ client.queryOptions("put", "/string-array");
+ // @ts-expect-error: Wrong path.
+ client.queryOptions("get", "/string-arrayX");
+ // @ts-expect-error: Missing 'post_id' param.
+ client.queryOptions("get", "/blogposts/{post_id}", {});
+ });
+
+ it("returns query options that can resolve data correctly with fetchQuery", async () => {
+ const response = { title: "title", body: "body" };
+ const fetchClient = createFetchClient({ baseUrl });
+ const client = createClient(fetchClient);
+
+ useMockRequestHandler({
+ baseUrl,
+ method: "get",
+ path: "/blogposts/1",
+ status: 200,
+ body: response,
+ });
+
+ const data = await queryClient.fetchQuery(
+ client.queryOptions("get", "/blogposts/{post_id}", {
+ params: {
+ path: {
+ post_id: "1",
+ },
+ },
+ }),
+ );
+
+ expectTypeOf(data).toEqualTypeOf<{
+ title: string;
+ body: string;
+ publish_date?: number;
+ }>();
+
+ expect(data).toEqual(response);
+ });
+
+ it("returns query options that can be passed to useQueries and have correct types inferred", async () => {
+ const fetchClient = createFetchClient({ baseUrl, fetch: fetchInfinite });
+ const client = createClient(fetchClient);
+
+ const { result } = renderHook(
+ () =>
+ useQueries(
+ {
+ queries: [
+ client.queryOptions("get", "/string-array"),
+ client.queryOptions("get", "/string-array", {}),
+ client.queryOptions("get", "/blogposts/{post_id}", {
+ params: {
+ path: {
+ post_id: "1",
+ },
+ },
+ }),
+ client.queryOptions("get", "/blogposts/{post_id}", {
+ params: {
+ path: {
+ post_id: "2",
+ },
+ },
+ }),
+ ],
+ },
+ queryClient,
+ ),
+ {
+ wrapper,
+ },
+ );
+
+ expectTypeOf(result.current[0].data).toEqualTypeOf();
+ expectTypeOf(result.current[0].error).toEqualTypeOf<{ code: number; message: string } | null>();
+
+ expectTypeOf(result.current[1]).toEqualTypeOf<(typeof result.current)[0]>();
+
+ expectTypeOf(result.current[2].data).toEqualTypeOf<
+ | {
+ title: string;
+ body: string;
+ publish_date?: number;
+ }
+ | undefined
+ >();
+ expectTypeOf(result.current[2].error).toEqualTypeOf<{ code: number; message: string } | null>();
+
+ expectTypeOf(result.current[3]).toEqualTypeOf<(typeof result.current)[2]>();
+
+ // Generated different queryKey for each query.
+ expect(queryClient.isFetching()).toBe(4);
+ });
+ });
+
describe("useQuery", () => {
it("should resolve data properly and have error as null when successfull request", async () => {
+ const response = ["one", "two", "three"];
const fetchClient = createFetchClient({ baseUrl });
const client = createClient(fetchClient);
@@ -54,7 +163,7 @@ describe("client", () => {
method: "get",
path: "/string-array",
status: 200,
- body: ["one", "two", "three"],
+ body: response,
});
const { result } = renderHook(() => client.useQuery("get", "/string-array"), {
@@ -65,9 +174,7 @@ describe("client", () => {
const { data, error } = result.current;
- // … is initially possibly undefined
- // @ts-expect-error
- expect(data[0]).toBe("one");
+ expect(data).toEqual(response);
expect(error).toBeNull();
});
@@ -116,8 +223,7 @@ describe("client", () => {
baseUrl,
fetch: async ({ signal }) => {
signalPassedToFetch = signal;
- await new Promise(() => {});
- return Response.error();
+ return await fetchInfinite();
},
});
const client = createClient(fetchClient);
@@ -185,6 +291,22 @@ describe("client", () => {
await waitFor(() => expect(rendered.getByText("data: hello")));
});
+
+ it("uses provided options", async () => {
+ const initialData = ["initial", "data"];
+ const fetchClient = createFetchClient({ baseUrl });
+ const client = createClient(fetchClient);
+
+ const { result } = renderHook(
+ () => client.useQuery("get", "/string-array", {}, { enabled: false, initialData }),
+ { wrapper },
+ );
+
+ const { data, error } = result.current;
+
+ expect(data).toBe(initialData);
+ expect(error).toBeNull();
+ });
});
describe("useSuspenseQuery", () => {