openapi-react-query
Version:
Fast, type-safe @tanstack/react-query client to work with your OpenAPI schema.
270 lines (253 loc) • 9.88 kB
text/typescript
import {
type UseMutationOptions,
type UseMutationResult,
type UseQueryOptions,
type UseQueryResult,
type InfiniteData,
type UseInfiniteQueryOptions,
type UseInfiniteQueryResult,
type UseSuspenseQueryOptions,
type UseSuspenseQueryResult,
type QueryClient,
type QueryFunctionContext,
type SkipToken,
useMutation,
useQuery,
useSuspenseQuery,
useInfiniteQuery,
} from "@tanstack/react-query";
import type {
ClientMethod,
FetchResponse,
MaybeOptionalInit,
Client as FetchClient,
DefaultParamsOption,
} from "openapi-fetch";
import type { HttpMethod, MediaType, PathsWithMethod, RequiredKeysOf } from "openapi-typescript-helpers";
// Helper type to dynamically infer the type from the `select` property
type InferSelectReturnType<TData, TSelect> = TSelect extends (data: TData) => infer R ? R : TData;
type InitWithUnknowns<Init> = Init & { [key: string]: unknown };
export type QueryKey<
Paths extends Record<string, Record<HttpMethod, {}>>,
Method extends HttpMethod,
Path extends PathsWithMethod<Paths, Method>,
Init = MaybeOptionalInit<Paths[Path], Method>,
> = Init extends undefined ? readonly [Method, Path] : readonly [Method, Path, Init];
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"],
InferSelectReturnType<Response["data"], Options["select"]>,
QueryKey<Paths, Method, Path>
>,
"queryKey" | "queryFn"
>,
>(
method: Method,
path: Path,
...[init, options]: RequiredKeysOf<Init> extends never
? [InitWithUnknowns<Init>?, Options?]
: [InitWithUnknowns<Init>, Options?]
) => NoInfer<
Omit<
UseQueryOptions<
Response["data"],
Response["error"],
InferSelectReturnType<Response["data"], Options["select"]>,
QueryKey<Paths, Method, Path>
>,
"queryFn"
> & {
queryFn: Exclude<
UseQueryOptions<
Response["data"],
Response["error"],
InferSelectReturnType<Response["data"], Options["select"]>,
QueryKey<Paths, Method, Path>
>["queryFn"],
SkipToken | undefined
>;
}
>;
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"],
InferSelectReturnType<Response["data"], Options["select"]>,
QueryKey<Paths, Method, Path>
>,
"queryKey" | "queryFn"
>,
>(
method: Method,
url: Path,
...[init, options, queryClient]: RequiredKeysOf<Init> extends never
? [InitWithUnknowns<Init>?, Options?, QueryClient?]
: [InitWithUnknowns<Init>, Options?, QueryClient?]
) => UseQueryResult<InferSelectReturnType<Response["data"], Options["select"]>, Response["error"]>;
export type UseInfiniteQueryMethod<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>>,
Options extends Omit<
UseInfiniteQueryOptions<
Response["data"],
Response["error"],
InfiniteData<Response["data"]>,
Response["data"],
QueryKey<Paths, Method, Path>,
unknown
>,
"queryKey" | "queryFn"
> & {
pageParamName?: string;
},
>(
method: Method,
url: Path,
init: InitWithUnknowns<Init>,
options: Options,
queryClient?: QueryClient,
) => UseInfiniteQueryResult<InfiniteData<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"],
InferSelectReturnType<Response["data"], Options["select"]>,
QueryKey<Paths, Method, Path>
>,
"queryKey" | "queryFn"
>,
>(
method: Method,
url: Path,
...[init, options, queryClient]: RequiredKeysOf<Init> extends never
? [InitWithUnknowns<Init>?, Options?, QueryClient?]
: [InitWithUnknowns<Init>, Options?, QueryClient?]
) => UseSuspenseQueryResult<InferSelectReturnType<Response["data"], Options["select"]>, Response["error"]>;
export type UseMutationMethod<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<UseMutationOptions<Response["data"], Response["error"], Init>, "mutationKey" | "mutationFn">,
>(
method: Method,
url: Path,
options?: Options,
queryClient?: QueryClient,
) => 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>;
useInfiniteQuery: UseInfiniteQueryMethod<Paths, Media>;
useMutation: UseMutationMethod<Paths, Media>;
}
export type MethodResponse<
CreatedClient extends OpenapiQueryClient<any, any>,
Method extends HttpMethod,
Path extends CreatedClient extends OpenapiQueryClient<infer Paths, infer _Media>
? PathsWithMethod<Paths, Method>
: never,
Options = object,
> = CreatedClient extends OpenapiQueryClient<infer Paths extends { [key: string]: any }, infer Media extends MediaType>
? NonNullable<FetchResponse<Paths[Path][Method], Options, Media>["data"]>
: never;
// 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) {
throw error;
}
return data;
};
const queryOptions: QueryOptionsFunction<Paths, Media> = (method, path, ...[init, options]) => ({
queryKey: (init === undefined ? ([method, path] as const) : ([method, path, init] as const)) as QueryKey<
Paths,
typeof method,
typeof path
>,
queryFn,
...options,
});
return {
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),
useInfiniteQuery: (method, path, init, options, queryClient) => {
const { pageParamName = "cursor", ...restOptions } = options;
const { queryKey } = queryOptions(method, path, init);
return useInfiniteQuery(
{
queryKey,
queryFn: async ({ queryKey: [method, path, init], pageParam = 0, signal }) => {
const mth = method.toUpperCase() as Uppercase<typeof method>;
const fn = client[mth] as ClientMethod<Paths, typeof method, Media>;
const mergedInit = {
...init,
signal,
params: {
...(init?.params || {}),
query: {
...(init?.params as { query?: DefaultParamsOption })?.query,
[pageParamName]: pageParam,
},
},
};
const { data, error } = await fn(path, mergedInit as any);
if (error) {
throw error;
}
return data;
},
...restOptions,
},
queryClient,
);
},
useMutation: (method, path, options, queryClient) =>
useMutation(
{
mutationKey: [method, path],
mutationFn: async (init) => {
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 InitWithUnknowns<typeof init>);
if (error) {
throw error;
}
return data as Exclude<typeof data, undefined>;
},
...options,
},
queryClient,
),
};
}