Skip to content

Commit

Permalink
feat(openapi-react-query): Introduce queryOptions
Browse files Browse the repository at this point in the history
  • Loading branch information
zsugabubus committed Aug 19, 2024
1 parent 68b5cb6 commit a6eff7d
Show file tree
Hide file tree
Showing 3 changed files with 203 additions and 55 deletions.
5 changes: 5 additions & 0 deletions .changeset/query-options.md
Original file line number Diff line number Diff line change
@@ -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.
118 changes: 68 additions & 50 deletions packages/openapi-react-query/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,39 +6,71 @@ import {
type UseSuspenseQueryOptions,
type UseSuspenseQueryResult,
type QueryClient,
type QueryFunctionContext,
useMutation,
useQuery,
useSuspenseQuery,
} from "@tanstack/react-query";
import type { ClientMethod, FetchResponse, MaybeOptionalInit, Client as FetchClient } from "openapi-fetch";
import type { HttpMethod, MediaType, PathsWithMethod, RequiredKeysOf } from "openapi-typescript-helpers";

type InitWithUnknowns<Init> = Init & { [key: string]: unknown };

export type QueryKey<
Paths extends Record<string, Record<HttpMethod, {}>>,
Method extends HttpMethod,
Path extends PathsWithMethod<Paths, Method>,
> = readonly [Method, Path, MaybeOptionalInit<Paths[Path], Method>];

export type QueryOptionsFunction<Paths extends Record<string, Record<HttpMethod, {}>>, Media extends MediaType> = <
Method extends HttpMethod,
Path extends PathsWithMethod<Paths, Method>,
Init extends MaybeOptionalInit<Paths[Path], Method>,
Response extends Required<FetchResponse<Paths[Path][Method], Init, Media>>, // note: Required is used to avoid repeating NonNullable in UseQuery types
Options extends Omit<
UseQueryOptions<Response["data"], Response["error"], Response["data"], QueryKey<Paths, Method, Path>>,
"queryKey" | "queryFn"
>,
>(
method: Method,
path: Path,
...[init, options]: RequiredKeysOf<Init> extends never
? [InitWithUnknowns<Init>?, Options?]
: [InitWithUnknowns<Init>, Options?]
) => UseQueryOptions<Response["data"], Response["error"], Response["data"], QueryKey<Paths, Method, Path>>;

export type UseQueryMethod<Paths extends Record<string, Record<HttpMethod, {}>>, Media extends MediaType> = <
Method extends HttpMethod,
Path extends PathsWithMethod<Paths, Method>,
Init extends MaybeOptionalInit<Paths[Path], Method>,
Response extends Required<FetchResponse<Paths[Path][Method], Init, Media>>, // note: Required is used to avoid repeating NonNullable in UseQuery types
Options extends Omit<UseQueryOptions<Response["data"], Response["error"]>, "queryKey" | "queryFn">,
Options extends Omit<
UseQueryOptions<Response["data"], Response["error"], Response["data"], QueryKey<Paths, Method, Path>>,
"queryKey" | "queryFn"
>,
>(
method: Method,
url: Path,
...[init, options, queryClient]: RequiredKeysOf<Init> extends never
? [(Init & { [key: string]: unknown })?, Options?, QueryClient?]
: [Init & { [key: string]: unknown }, Options?, QueryClient?]
? [InitWithUnknowns<Init>?, Options?, QueryClient?]
: [InitWithUnknowns<Init>, Options?, QueryClient?]
) => UseQueryResult<Response["data"], Response["error"]>;

export type UseSuspenseQueryMethod<Paths extends Record<string, Record<HttpMethod, {}>>, Media extends MediaType> = <
Method extends HttpMethod,
Path extends PathsWithMethod<Paths, Method>,
Init extends MaybeOptionalInit<Paths[Path], Method>,
Response extends Required<FetchResponse<Paths[Path][Method], Init, Media>>, // note: Required is used to avoid repeating NonNullable in UseQuery types
Options extends Omit<UseSuspenseQueryOptions<Response["data"], Response["error"]>, "queryKey" | "queryFn">,
Options extends Omit<
UseSuspenseQueryOptions<Response["data"], Response["error"], Response["data"], QueryKey<Paths, Method, Path>>,
"queryKey" | "queryFn"
>,
>(
method: Method,
url: Path,
...[init, options, queryClient]: RequiredKeysOf<Init> extends never
? [(Init & { [key: string]: unknown })?, Options?, QueryClient?]
: [Init & { [key: string]: unknown }, Options?, QueryClient?]
? [InitWithUnknowns<Init>?, Options?, QueryClient?]
: [InitWithUnknowns<Init>, Options?, QueryClient?]
) => UseSuspenseQueryResult<Response["data"], Response["error"]>;

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

export interface OpenapiQueryClient<Paths extends {}, Media extends MediaType = MediaType> {
queryOptions: QueryOptionsFunction<Paths, Media>;
useQuery: UseQueryMethod<Paths, Media>;
useSuspenseQuery: UseSuspenseQueryMethod<Paths, Media>;
useMutation: UseMutationMethod<Paths, Media>;
}

// TODO: Move the client[method]() fn outside for reusability
// TODO: Add the ability to bring queryClient as argument
export default function createClient<Paths extends {}, Media extends MediaType = MediaType>(
client: FetchClient<Paths, Media>,
): OpenapiQueryClient<Paths, Media> {
const queryFn = async <Method extends HttpMethod, Path extends PathsWithMethod<Paths, Method>>({
queryKey: [method, path, init],
signal,
}: QueryFunctionContext<QueryKey<Paths, Method, Path>>) => {
const mth = method.toUpperCase() as Uppercase<typeof method>;
const fn = client[mth] as ClientMethod<Paths, typeof method, Media>;
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<Paths, Media> = (method, path, ...[init, options]) => ({
queryKey: [method, path, init as InitWithUnknowns<typeof init>] 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<Paths, typeof method, Media>;
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<Paths, typeof method, Media>;
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<typeof init>, options), queryClient),
useSuspenseQuery: (method, path, ...[init, options, queryClient]) =>
useSuspenseQuery(queryOptions(method, path, init as InitWithUnknowns<typeof init>, 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<typeof method>;
const fn = client[mth] as ClientMethod<Paths, typeof method, Media>;
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<typeof init>);
if (error || !data) {
throw error;
}
Expand All @@ -119,7 +138,6 @@ export default function createClient<Paths extends {}, Media extends MediaType =
...options,
},
queryClient,
);
},
),
};
}
135 changes: 130 additions & 5 deletions packages/openapi-react-query/test/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { paths } from "./fixtures/api.js";
import createClient from "../src/index.js";
import createFetchClient from "openapi-fetch";
import { fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QueryClient, QueryClientProvider, useQueries } from "@tanstack/react-query";
import { Suspense, type ReactNode } from "react";
import { ErrorBoundary } from "react-error-boundary";

Expand All @@ -20,6 +20,11 @@ const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);

const fetchInfinite = async () => {
await new Promise(() => {});
return Response.error();
};

beforeAll(() => {
server.listen({
onUnhandledRequest: (request) => {
Expand All @@ -39,13 +44,119 @@ describe("client", () => {
it("generates all proper functions", () => {
const fetchClient = createFetchClient<paths>({ baseUrl });
const client = createClient<paths>(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<paths>({ 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<paths>({ 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<paths>({ 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<string[] | undefined>();
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<paths>({ baseUrl });
const client = createClient(fetchClient);

Expand All @@ -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"), {
Expand All @@ -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();
});

Expand Down Expand Up @@ -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<paths>({ 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", () => {
Expand Down

0 comments on commit a6eff7d

Please sign in to comment.