Skip to content

feat(openapi-react-query): Introduce createQuery #1858

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Sep 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
1 change: 1 addition & 0 deletions docs/.vitepress/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
],
},
Expand Down
123 changes: 123 additions & 0 deletions docs/openapi-react-query/query-options.md
Original file line number Diff line number Diff line change
@@ -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 <div>{data.firstname}</div>;
};
```

```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<paths>({
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<paths>({
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.
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, { signal, ...(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 ({ signal }) => {
const mth = method.toUpperCase() as keyof typeof client;
const fn = client[mth] as ClientMethod<Paths, typeof method, Media>;
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<Paths, typeof method, Media>;
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<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,
);
},
),
};
}
Loading