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", () => {