diff --git a/.changeset/query-options.md b/.changeset/query-options.md new file mode 100644 index 00000000..ba194f6b --- /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/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts index 438c7aa9..59fdf87f 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, 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 () => { - const mth = method.toUpperCase() as keyof typeof client; - const fn = client[mth] as ClientMethod; - const { data, error } = await fn(path, 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 () => { - const mth = method.toUpperCase() as keyof typeof client; - const fn = client[mth] as ClientMethod; - const { data, error } = await fn(path, 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,119 @@ 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 queryClient = new QueryClient(); + 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 queryClient = new QueryClient(); + 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 +165,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 +176,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(); }); @@ -165,6 +274,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", () => {