From 4962aaa9671b874be7d7af0fd3cd2d66f929aac0 Mon Sep 17 00:00:00 2001 From: minchodang Date: Mon, 6 Oct 2025 15:29:17 +0900 Subject: [PATCH 1/6] feat: :sparkles: mutationOptions add with test code --- packages/openapi-react-query/src/index.ts | 250 +++++++++++++----- .../openapi-react-query/test/index.test.tsx | 184 ++++++++++++- 2 files changed, 373 insertions(+), 61 deletions(-) diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts index 337919ac3..c5af1aced 100644 --- a/packages/openapi-react-query/src/index.ts +++ b/packages/openapi-react-query/src/index.ts @@ -37,19 +37,27 @@ export type QueryKey< Init = MaybeOptionalInit, > = Init extends undefined ? readonly [Method, Path] : readonly [Method, Path, Init]; -export type QueryOptionsFunction>, Media extends MediaType> = < +export type MutationKey< + Method extends HttpMethod, + Path, +> = readonly [Method, Path]; + +export type QueryOptionsFunction< + Paths extends Record>, + 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< - Response["data"], - Response["error"], - InferSelectReturnType, + Response['data'], + Response['error'], + InferSelectReturnType, QueryKey >, - "queryKey" | "queryFn" + 'queryKey' | 'queryFn' >, >( method: Method, @@ -60,38 +68,122 @@ export type QueryOptionsFunction NoInfer< Omit< UseQueryOptions< - Response["data"], - Response["error"], - InferSelectReturnType, + Response['data'], + Response['error'], + InferSelectReturnType, QueryKey >, - "queryFn" + 'queryFn' > & { queryFn: Exclude< UseQueryOptions< - Response["data"], - Response["error"], - InferSelectReturnType, + Response['data'], + Response['error'], + InferSelectReturnType, QueryKey - >["queryFn"], + >['queryFn'], SkipToken | undefined >; } >; -export type UseQueryMethod>, Media extends MediaType> = < +export type MutationOptionsFunction< + Paths extends Record>, + Media extends MediaType, +> = < + Method extends HttpMethod, + Path extends PathsWithMethod, + Init extends MaybeOptionalInit, + Response extends Required>, + Options extends Omit< + UseMutationOptions, + 'mutationKey' | 'mutationFn' + >, +>( + method: Method, + path: Path, + options?: Options +) => NoInfer< + Omit< + UseMutationOptions, + 'mutationFn' + > & { + mutationFn: Exclude< + UseMutationOptions['mutationFn'], + undefined + >; + } +>; + +// Helper type to infer TPageParam type +type InferPageParamType = T extends { initialPageParam: infer P } ? P : unknown; + +export type InfiniteQueryOptionsFunction< + Paths extends Record>, + Media extends MediaType, +> = < + Method extends HttpMethod, + Path extends PathsWithMethod, + Init extends MaybeOptionalInit, + Response extends Required>, + Options extends Omit< + UseInfiniteQueryOptions< + Response['data'], + Response['error'], + InferSelectReturnType, Options['select']>, + QueryKey, + InferPageParamType + >, + 'queryKey' | 'queryFn' + > & { + pageParamName?: string; + initialPageParam: InferPageParamType; + }, +>( + method: Method, + path: Path, + init: InitWithUnknowns, + options: Options +) => NoInfer< + Omit< + UseInfiniteQueryOptions< + Response['data'], + Response['error'], + InferSelectReturnType, Options['select']>, + QueryKey, + InferPageParamType + >, + 'queryFn' + > & { + queryFn: Exclude< + UseInfiniteQueryOptions< + Response['data'], + Response['error'], + InferSelectReturnType, Options['select']>, + QueryKey, + InferPageParamType + >['queryFn'], + SkipToken | undefined + >; + } +>; + +export type UseQueryMethod< + Paths extends Record>, + 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< - Response["data"], - Response["error"], - InferSelectReturnType, + Response['data'], + Response['error'], + InferSelectReturnType, QueryKey >, - "queryKey" | "queryFn" + 'queryKey' | 'queryFn' >, >( method: Method, @@ -99,22 +191,25 @@ export type UseQueryMethod>, ...[init, options, queryClient]: RequiredKeysOf extends never ? [InitWithUnknowns?, Options?, QueryClient?] : [InitWithUnknowns, Options?, QueryClient?] -) => UseQueryResult, Response["error"]>; +) => UseQueryResult, Response['error']>; -export type UseInfiniteQueryMethod>, Media extends MediaType> = < +export type UseInfiniteQueryMethod< + Paths extends Record>, + Media extends MediaType, +> = < Method extends HttpMethod, Path extends PathsWithMethod, Init extends MaybeOptionalInit, Response extends Required>, Options extends Omit< UseInfiniteQueryOptions< - Response["data"], - Response["error"], - InferSelectReturnType, Options["select"]>, + Response['data'], + Response['error'], + InferSelectReturnType, Options['select']>, QueryKey, unknown >, - "queryKey" | "queryFn" + 'queryKey' | 'queryFn' > & { pageParamName?: string; }, @@ -123,25 +218,28 @@ export type UseInfiniteQueryMethod, options: Options, - queryClient?: QueryClient, + queryClient?: QueryClient ) => UseInfiniteQueryResult< - InferSelectReturnType, Options["select"]>, - Response["error"] + InferSelectReturnType, Options['select']>, + Response['error'] >; -export type UseSuspenseQueryMethod>, Media extends MediaType> = < +export type UseSuspenseQueryMethod< + Paths extends Record>, + 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< UseSuspenseQueryOptions< - Response["data"], - Response["error"], - InferSelectReturnType, + Response['data'], + Response['error'], + InferSelectReturnType, QueryKey >, - "queryKey" | "queryFn" + 'queryKey' | 'queryFn' >, >( method: Method, @@ -149,23 +247,33 @@ export type UseSuspenseQueryMethod extends never ? [InitWithUnknowns?, Options?, QueryClient?] : [InitWithUnknowns, Options?, QueryClient?] -) => UseSuspenseQueryResult, Response["error"]>; +) => UseSuspenseQueryResult< + InferSelectReturnType, + Response['error'] +>; -export type UseMutationMethod>, Media extends MediaType> = < +export type UseMutationMethod< + Paths extends Record>, + 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, "mutationKey" | "mutationFn">, + Options extends Omit< + UseMutationOptions, + 'mutationKey' | 'mutationFn' + >, >( method: Method, url: Path, options?: Options, - queryClient?: QueryClient, -) => UseMutationResult; + queryClient?: QueryClient +) => UseMutationResult; export interface OpenapiQueryClient { queryOptions: QueryOptionsFunction; + mutationOptions: MutationOptionsFunction; useQuery: UseQueryMethod; useSuspenseQuery: UseSuspenseQueryMethod; useInfiniteQuery: UseInfiniteQueryMethod; @@ -179,13 +287,17 @@ export type MethodResponse< ? PathsWithMethod : never, Options = object, -> = CreatedClient extends OpenapiQueryClient - ? NonNullable["data"]> - : never; +> = + CreatedClient extends OpenapiQueryClient< + infer Paths extends { [key: string]: any }, + infer Media extends MediaType + > + ? NonNullable['data']> + : never; // TODO: Add the ability to bring queryClient as argument export default function createClient( - client: FetchClient, + client: FetchClient ): OpenapiQueryClient { const queryFn = async >({ queryKey: [method, path, init], @@ -197,27 +309,54 @@ export default function createClient + >( + method: Method, + path: Path + ) => { + return async (init: any) => { + const mth = method.toUpperCase() as Uppercase; + const fn = client[mth] as ClientMethod; + const { data, error } = await fn(path, init as any); + if (error) { + throw error; + } + + return data as Exclude; + }; + }; + const queryOptions: QueryOptionsFunction = (method, path, ...[init, options]) => ({ - queryKey: (init === undefined ? ([method, path] as const) : ([method, path, init] as const)) as QueryKey< - Paths, - typeof method, - typeof path - >, + queryKey: (init === undefined + ? ([method, path] as const) + : ([method, path, init] as const)) as QueryKey, queryFn, ...options, }); + const mutationOptions: MutationOptionsFunction = (method, path, options) => ({ + mutationKey: [method, path] as MutationKey, + mutationFn: createMutationFn(method, path), + ...options, + }); + return { queryOptions, + mutationOptions, useQuery: (method, path, ...[init, options, queryClient]) => - useQuery(queryOptions(method, path, init as InitWithUnknowns, 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), useInfiniteQuery: (method, path, init, options, queryClient) => { @@ -256,19 +395,10 @@ export default function createClient { - const mth = method.toUpperCase() as Uppercase; - const fn = client[mth] as ClientMethod; - const { data, error } = await fn(path, init as InitWithUnknowns); - if (error) { - throw error; - } - - return data as Exclude; - }, + mutationFn: createMutationFn(method, path), ...options, }, - queryClient, + queryClient ), }; -} +} \ No newline at end of file diff --git a/packages/openapi-react-query/test/index.test.tsx b/packages/openapi-react-query/test/index.test.tsx index 4dcca2eee..ebdfe9fb3 100644 --- a/packages/openapi-react-query/test/index.test.tsx +++ b/packages/openapi-react-query/test/index.test.tsx @@ -1,6 +1,7 @@ import { QueryClient, QueryClientProvider, + useMutation, skipToken, useQueries, useQuery, @@ -10,10 +11,12 @@ import { act, fireEvent, render, renderHook, screen, waitFor } from "@testing-li import createFetchClient from "openapi-fetch"; import { type ReactNode, Suspense } from "react"; import { ErrorBoundary } from "react-error-boundary"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; import createClient, { type MethodResponse } from "../src/index.js"; import type { paths } from "./fixtures/api.js"; import { baseUrl, server, useMockRequestHandler } from "./fixtures/mock-server.js"; +import { afterAll, afterEach, beforeAll, describe, expect, expectTypeOf, it, vi } from "vitest"; + +const mini = "3"; type minimalGetPaths = { // Without parameters. @@ -71,6 +74,7 @@ describe("client", () => { it("generates all proper functions", () => { const fetchClient = createFetchClient({ baseUrl }); const client = createClient(fetchClient); + expect(client).toHaveProperty("mutationOptions"); expect(client).toHaveProperty("queryOptions"); expect(client).toHaveProperty("useQuery"); expect(client).toHaveProperty("useSuspenseQuery"); @@ -643,6 +647,184 @@ describe("client", () => { expect(signalPassedToFetch?.aborted).toBeTruthy(); }); }); + describe("mutationOptions", () => { + it("has correct parameter types", async () => { + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient); + + client.mutationOptions("put", "/comment"); + // @ts-expect-error: Wrong method. + client.mutationOptions("get", "/comment"); + // @ts-expect-error: Wrong path. + client.mutationOptions("put", "/commentX"); + // @ts-expect-error: Missing required body param. + client.mutationOptions("post", "/blogposts/{post_id}/comment", {}); + }); + + it("returns mutation options that can be passed to useMutation", async () => { + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient); + + useMockRequestHandler({ + baseUrl, + method: "put", + path: "/comment", + status: 200, + body: { message: "Hello World" }, + }); + + const options = client.mutationOptions("put", "/comment"); + + expect(options).toHaveProperty("mutationKey"); + expect(options).toHaveProperty("mutationFn"); + expect(Array.isArray(options.mutationKey)).toBe(true); + expectTypeOf(options.mutationFn).toBeFunction(); + + const { result } = renderHook(() => useMutation(options), { wrapper }); + + result.current.mutate({ body: { message: "Hello World", replied_at: 123456789 } }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.message).toBe("Hello World"); + }); + + it("returns mutation options that can resolve data correctly with mutateAsync", async () => { + const response = { message: "Updated successfully" }; + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient); + + useMockRequestHandler({ + baseUrl, + method: "put", + path: "/comment", + status: 200, + body: response, + }); + + const options = client.mutationOptions("put", "/comment"); + const { result } = renderHook(() => useMutation(options), { wrapper }); + + const data = await result.current.mutateAsync({ + body: { message: "Test message", replied_at: 123456789 }, + }); + + expectTypeOf(data).toEqualTypeOf<{ + message: string; + }>(); + + expect(data).toEqual(response); + }); + + it("returns mutation options that handle error responses correctly", async () => { + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient); + + useMockRequestHandler({ + baseUrl, + method: "put", + path: "/comment", + status: 500, + body: { code: 500, message: "Internal Server Error" }, + }); + + const options = client.mutationOptions("put", "/comment"); + const { result } = renderHook(() => useMutation(options), { wrapper }); + + result.current.mutate({ body: { message: "Test message", replied_at: 123456789 } }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error?.message).toBe("Internal Server Error"); + expect(result.current.data).toBeUndefined(); + }); + + it("returns mutation options with path parameters", async () => { + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient); + + useMockRequestHandler({ + baseUrl, + method: "put", + path: "/blogposts", + status: 201, + body: { status: "Comment Created" }, + }); + + const options = client.mutationOptions("put", "/blogposts"); + const { result } = renderHook(() => useMutation(options), { wrapper }); + + result.current.mutate({ + body: { + body: "Post test", + title: "Post Create", + publish_date: 3333333, + }, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.status).toBe("Comment Created"); + }); + + it("returns mutation options that handle null response body", async () => { + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient); + + useMockRequestHandler({ + baseUrl, + method: "delete", + path: "/blogposts/:post_id", + status: 204, + body: null, + }); + + const options = client.mutationOptions("delete", "/blogposts/{post_id}"); + const { result } = renderHook(() => useMutation(options), { wrapper }); + + result.current.mutate({ + params: { + path: { + post_id: "1", + }, + }, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.error).toBeNull(); + }); + + it("returns mutation options that can be used with custom mutation options", async () => { + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient); + + useMockRequestHandler({ + baseUrl, + method: "put", + path: "/comment", + status: 200, + body: { message: "Success" }, + }); + + const onSuccessSpy = vi.fn(); + const onErrorSpy = vi.fn(); + + const options = { + ...client.mutationOptions("put", "/comment"), + onSuccess: onSuccessSpy, + onError: onErrorSpy, + }; + + const { result } = renderHook(() => useMutation(options), { wrapper }); + + result.current.mutate({ body: { message: "Test", replied_at: 123456789 } }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(onSuccessSpy).toHaveBeenCalledWith( + { message: "Success" }, + { body: { message: "Test", replied_at: 123456789 } }, + undefined, + ); + expect(onErrorSpy).not.toHaveBeenCalled(); + }); + }); describe("useMutation", () => { describe("mutate", () => { From b015044ec1f64a0feefb49a326eb7f2b7730dc2e Mon Sep 17 00:00:00 2001 From: minchodang Date: Mon, 6 Oct 2025 15:43:27 +0900 Subject: [PATCH 2/6] chore: biome lint error fix --- packages/openapi-react-query/src/index.ts | 186 +++++++----------- .../openapi-react-query/test/index.test.tsx | 6 +- 2 files changed, 73 insertions(+), 119 deletions(-) diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts index c5af1aced..9e916e6fb 100644 --- a/packages/openapi-react-query/src/index.ts +++ b/packages/openapi-react-query/src/index.ts @@ -37,27 +37,21 @@ export type QueryKey< Init = MaybeOptionalInit, > = Init extends undefined ? readonly [Method, Path] : readonly [Method, Path, Init]; -export type MutationKey< - Method extends HttpMethod, - Path, -> = readonly [Method, Path]; +export type MutationKey = readonly [Method, Path]; -export type QueryOptionsFunction< - Paths extends Record>, - Media extends MediaType, -> = < +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< - Response['data'], - Response['error'], - InferSelectReturnType, + Response["data"], + Response["error"], + InferSelectReturnType, QueryKey >, - 'queryKey' | 'queryFn' + "queryKey" | "queryFn" >, >( method: Method, @@ -68,50 +62,38 @@ export type QueryOptionsFunction< ) => NoInfer< Omit< UseQueryOptions< - Response['data'], - Response['error'], - InferSelectReturnType, + Response["data"], + Response["error"], + InferSelectReturnType, QueryKey >, - 'queryFn' + "queryFn" > & { queryFn: Exclude< UseQueryOptions< - Response['data'], - Response['error'], - InferSelectReturnType, + Response["data"], + Response["error"], + InferSelectReturnType, QueryKey - >['queryFn'], + >["queryFn"], SkipToken | undefined >; } >; -export type MutationOptionsFunction< - Paths extends Record>, - Media extends MediaType, -> = < +export type MutationOptionsFunction>, Media extends MediaType> = < Method extends HttpMethod, Path extends PathsWithMethod, Init extends MaybeOptionalInit, Response extends Required>, - Options extends Omit< - UseMutationOptions, - 'mutationKey' | 'mutationFn' - >, + Options extends Omit, "mutationKey" | "mutationFn">, >( method: Method, path: Path, - options?: Options + options?: Options, ) => NoInfer< - Omit< - UseMutationOptions, - 'mutationFn' - > & { - mutationFn: Exclude< - UseMutationOptions['mutationFn'], - undefined - >; + Omit, "mutationFn"> & { + mutationFn: Exclude["mutationFn"], undefined>; } >; @@ -128,13 +110,13 @@ export type InfiniteQueryOptionsFunction< Response extends Required>, Options extends Omit< UseInfiniteQueryOptions< - Response['data'], - Response['error'], - InferSelectReturnType, Options['select']>, + Response["data"], + Response["error"], + InferSelectReturnType, Options["select"]>, QueryKey, InferPageParamType >, - 'queryKey' | 'queryFn' + "queryKey" | "queryFn" > & { pageParamName?: string; initialPageParam: InferPageParamType; @@ -143,47 +125,44 @@ export type InfiniteQueryOptionsFunction< method: Method, path: Path, init: InitWithUnknowns, - options: Options + options: Options, ) => NoInfer< Omit< UseInfiniteQueryOptions< - Response['data'], - Response['error'], - InferSelectReturnType, Options['select']>, + Response["data"], + Response["error"], + InferSelectReturnType, Options["select"]>, QueryKey, InferPageParamType >, - 'queryFn' + "queryFn" > & { queryFn: Exclude< UseInfiniteQueryOptions< - Response['data'], - Response['error'], - InferSelectReturnType, Options['select']>, + Response["data"], + Response["error"], + InferSelectReturnType, Options["select"]>, QueryKey, InferPageParamType - >['queryFn'], + >["queryFn"], SkipToken | undefined >; } >; -export type UseQueryMethod< - Paths extends Record>, - Media extends MediaType, -> = < +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< UseQueryOptions< - Response['data'], - Response['error'], - InferSelectReturnType, + Response["data"], + Response["error"], + InferSelectReturnType, QueryKey >, - 'queryKey' | 'queryFn' + "queryKey" | "queryFn" >, >( method: Method, @@ -191,25 +170,22 @@ export type UseQueryMethod< ...[init, options, queryClient]: RequiredKeysOf extends never ? [InitWithUnknowns?, Options?, QueryClient?] : [InitWithUnknowns, Options?, QueryClient?] -) => UseQueryResult, Response['error']>; +) => UseQueryResult, Response["error"]>; -export type UseInfiniteQueryMethod< - Paths extends Record>, - Media extends MediaType, -> = < +export type UseInfiniteQueryMethod>, Media extends MediaType> = < Method extends HttpMethod, Path extends PathsWithMethod, Init extends MaybeOptionalInit, Response extends Required>, Options extends Omit< UseInfiniteQueryOptions< - Response['data'], - Response['error'], - InferSelectReturnType, Options['select']>, + Response["data"], + Response["error"], + InferSelectReturnType, Options["select"]>, QueryKey, unknown >, - 'queryKey' | 'queryFn' + "queryKey" | "queryFn" > & { pageParamName?: string; }, @@ -218,28 +194,25 @@ export type UseInfiniteQueryMethod< url: Path, init: InitWithUnknowns, options: Options, - queryClient?: QueryClient + queryClient?: QueryClient, ) => UseInfiniteQueryResult< - InferSelectReturnType, Options['select']>, - Response['error'] + InferSelectReturnType, Options["select"]>, + Response["error"] >; -export type UseSuspenseQueryMethod< - Paths extends Record>, - Media extends MediaType, -> = < +export type UseSuspenseQueryMethod>, 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< UseSuspenseQueryOptions< - Response['data'], - Response['error'], - InferSelectReturnType, + Response["data"], + Response["error"], + InferSelectReturnType, QueryKey >, - 'queryKey' | 'queryFn' + "queryKey" | "queryFn" >, >( method: Method, @@ -247,29 +220,20 @@ export type UseSuspenseQueryMethod< ...[init, options, queryClient]: RequiredKeysOf extends never ? [InitWithUnknowns?, Options?, QueryClient?] : [InitWithUnknowns, Options?, QueryClient?] -) => UseSuspenseQueryResult< - InferSelectReturnType, - Response['error'] ->; +) => UseSuspenseQueryResult, Response["error"]>; -export type UseMutationMethod< - Paths extends Record>, - Media extends MediaType, -> = < +export type UseMutationMethod>, 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< - UseMutationOptions, - 'mutationKey' | 'mutationFn' - >, + Options extends Omit, "mutationKey" | "mutationFn">, >( method: Method, url: Path, options?: Options, - queryClient?: QueryClient -) => UseMutationResult; + queryClient?: QueryClient, +) => UseMutationResult; export interface OpenapiQueryClient { queryOptions: QueryOptionsFunction; @@ -287,17 +251,13 @@ export type MethodResponse< ? PathsWithMethod : never, Options = object, -> = - CreatedClient extends OpenapiQueryClient< - infer Paths extends { [key: string]: any }, - infer Media extends MediaType - > - ? NonNullable['data']> - : never; +> = CreatedClient extends OpenapiQueryClient + ? NonNullable["data"]> + : never; // TODO: Add the ability to bring queryClient as argument export default function createClient( - client: FetchClient + client: FetchClient, ): OpenapiQueryClient { const queryFn = async >({ queryKey: [method, path, init], @@ -309,19 +269,16 @@ export default function createClient - >( + const createMutationFn = >( method: Method, - path: Path + path: Path, ) => { return async (init: any) => { const mth = method.toUpperCase() as Uppercase; @@ -336,9 +293,11 @@ export default function createClient = (method, path, ...[init, options]) => ({ - queryKey: (init === undefined - ? ([method, path] as const) - : ([method, path, init] as const)) as QueryKey, + queryKey: (init === undefined ? ([method, path] as const) : ([method, path, init] as const)) as QueryKey< + Paths, + typeof method, + typeof path + >, queryFn, ...options, }); @@ -353,10 +312,7 @@ export default function createClient - useQuery( - queryOptions(method, path, init as InitWithUnknowns, 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), useInfiniteQuery: (method, path, init, options, queryClient) => { @@ -398,7 +354,7 @@ export default function createClient Date: Mon, 6 Oct 2025 17:23:32 +0900 Subject: [PATCH 3/6] docs: add mutation-options.md --- docs/.vitepress/en.ts | 1 + docs/openapi-react-query/mutation-options.md | 88 ++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 docs/openapi-react-query/mutation-options.md diff --git a/docs/.vitepress/en.ts b/docs/.vitepress/en.ts index b70514df7..c6f69347b 100644 --- a/docs/.vitepress/en.ts +++ b/docs/.vitepress/en.ts @@ -76,6 +76,7 @@ export default defineConfig({ { text: "useSuspenseQuery", link: "/use-suspense-query" }, { text: "useInfiniteQuery", link: "/use-infinite-query" }, { text: "queryOptions", link: "/query-options" }, + { text: "mutationOptions", link: "/mutation-options" }, ], }, { diff --git a/docs/openapi-react-query/mutation-options.md b/docs/openapi-react-query/mutation-options.md new file mode 100644 index 000000000..a9fe5a6ee --- /dev/null +++ b/docs/openapi-react-query/mutation-options.md @@ -0,0 +1,88 @@ +--- +title: mutationOptions +--- + +# {{ $frontmatter.title }} + +The `mutationOptions` method lets you build type-safe [Mutation Options](https://tanstack.com/query/latest/docs/framework/react/reference/mutationOptions) that plug directly into React Query APIs. + +Use it whenever you want to call `$api` mutations but still provide your own `useMutation` (or other mutation consumer) configuration. The helper wires up the correct `mutationKey`, generates a fetcher that calls your OpenAPI endpoint, and preserves the inferred `data` and `error` types. + +## Examples + +Rewriting the [useMutation example](use-mutation#example) to use `mutationOptions`. + +::: code-group + +```tsx [src/app.tsx] +import { useMutation } from '@tanstack/react-query'; +import { $api } from './api'; + +export const App = () => { + const updateUser = useMutation( + $api.mutationOptions('patch', '/users/{user_id}', { + onSuccess: (data, variables) => { + console.log('Updated', variables.params?.path?.user_id, data.firstname); + }, + }) + ); + + return ( + + ); +}; +``` + +```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 + +`$api.useMutation` uses the same fetcher and key contract as `mutationOptions`. Reach for `mutationOptions` when you need to share mutation configuration across components or call React Query utilities such as `queryClient.mutationDefaults`. + +::: + +## API + +```tsx +const options = $api.mutationOptions(method, path, mutationOptions); +``` + +**Arguments** + +- `method` **(required)** + - HTTP method of the OpenAPI operation. + - Also used as the first element of the mutation key. +- `path` **(required)** + - Pathname of the OpenAPI operation. + - Must be valid for the given method in your generated schema. + - Used as the second element of the mutation key. +- `mutationOptions` + - Optional `UseMutationOptions` for React Query. + - You can set callbacks (`onSuccess`, `onSettled`, …), retry behaviour, and every option except `mutationKey` and `mutationFn` (those are provided for you). + +**Returns** + +- [Mutation Options](https://tanstack.com/query/latest/docs/framework/react/reference/mutationOptions) + - `mutationKey` is `[method, path]`. + - `mutationFn` is a strongly typed fetcher that calls `openapi-fetch` with your `init` payload. + - `data` and `error` types match the OpenAPI schema, so `variables` inside callbacks are typed as the request shape. From 7ca6d5b87c24119cfdcdc1fc1ece01cd5a4504f1 Mon Sep 17 00:00:00 2001 From: minchodang Date: Mon, 6 Oct 2025 17:42:58 +0900 Subject: [PATCH 4/6] fix(test-code): adjust mutationOptions onSuccess test for React Query v5 context --- packages/openapi-react-query/test/index.test.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/openapi-react-query/test/index.test.tsx b/packages/openapi-react-query/test/index.test.tsx index db1c5cac7..b43ea718d 100644 --- a/packages/openapi-react-query/test/index.test.tsx +++ b/packages/openapi-react-query/test/index.test.tsx @@ -815,10 +815,14 @@ describe("client", () => { result.current.mutate({ body: { message: "Test", replied_at: 123456789 } }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(onSuccessSpy).toHaveBeenCalledWith( + expect(onSuccessSpy).toHaveBeenNthCalledWith( + 1, { message: "Success" }, { body: { message: "Test", replied_at: 123456789 } }, undefined, + expect.objectContaining({ + mutationKey: ["put", "/comment"], + }), ); expect(onErrorSpy).not.toHaveBeenCalled(); }); From bfdcc57a096b998293ba1e3a2b21ffb9a0245712 Mon Sep 17 00:00:00 2001 From: minchodang Date: Tue, 28 Oct 2025 08:03:39 +0900 Subject: [PATCH 5/6] refactor: remove unused infiniteQueryOptions type and fix test - Remove InfiniteQueryOptionsFunction type (not used) - Fix mutation onSuccess test to match React Query callback signature --- packages/openapi-react-query/src/index.ts | 53 ------------------- .../openapi-react-query/test/index.test.tsx | 5 +- 2 files changed, 2 insertions(+), 56 deletions(-) diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts index 9e916e6fb..a05f206a5 100644 --- a/packages/openapi-react-query/src/index.ts +++ b/packages/openapi-react-query/src/index.ts @@ -97,59 +97,6 @@ export type MutationOptionsFunction; -// Helper type to infer TPageParam type -type InferPageParamType = T extends { initialPageParam: infer P } ? P : unknown; - -export type InfiniteQueryOptionsFunction< - Paths extends Record>, - Media extends MediaType, -> = < - Method extends HttpMethod, - Path extends PathsWithMethod, - Init extends MaybeOptionalInit, - Response extends Required>, - Options extends Omit< - UseInfiniteQueryOptions< - Response["data"], - Response["error"], - InferSelectReturnType, Options["select"]>, - QueryKey, - InferPageParamType - >, - "queryKey" | "queryFn" - > & { - pageParamName?: string; - initialPageParam: InferPageParamType; - }, ->( - method: Method, - path: Path, - init: InitWithUnknowns, - options: Options, -) => NoInfer< - Omit< - UseInfiniteQueryOptions< - Response["data"], - Response["error"], - InferSelectReturnType, Options["select"]>, - QueryKey, - InferPageParamType - >, - "queryFn" - > & { - queryFn: Exclude< - UseInfiniteQueryOptions< - Response["data"], - Response["error"], - InferSelectReturnType, Options["select"]>, - QueryKey, - InferPageParamType - >["queryFn"], - SkipToken | undefined - >; - } ->; - export type UseQueryMethod>, Media extends MediaType> = < Method extends HttpMethod, Path extends PathsWithMethod, diff --git a/packages/openapi-react-query/test/index.test.tsx b/packages/openapi-react-query/test/index.test.tsx index b43ea718d..dacaff4ec 100644 --- a/packages/openapi-react-query/test/index.test.tsx +++ b/packages/openapi-react-query/test/index.test.tsx @@ -820,10 +820,9 @@ describe("client", () => { { message: "Success" }, { body: { message: "Test", replied_at: 123456789 } }, undefined, - expect.objectContaining({ - mutationKey: ["put", "/comment"], - }), ); + // Verify that mutationKey is part of the options object + expect(options).toEqual(expect.objectContaining({ mutationKey: ["put", "/comment"] })); expect(onErrorSpy).not.toHaveBeenCalled(); }); }); From f2ca453d3777e91cad25d8e73d754255035498c5 Mon Sep 17 00:00:00 2001 From: minchodang Date: Tue, 28 Oct 2025 08:15:54 +0900 Subject: [PATCH 6/6] test: fix mutation onSuccess assertion to handle varying React Query callback signatures React Query's onSuccess callback signature varies between versions. Instead of asserting exact call arguments, now verify only the first two arguments (data and variables) using mock.calls to be version-agnostic. --- packages/openapi-react-query/test/index.test.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/openapi-react-query/test/index.test.tsx b/packages/openapi-react-query/test/index.test.tsx index dacaff4ec..1f82caf9c 100644 --- a/packages/openapi-react-query/test/index.test.tsx +++ b/packages/openapi-react-query/test/index.test.tsx @@ -815,12 +815,11 @@ describe("client", () => { result.current.mutate({ body: { message: "Test", replied_at: 123456789 } }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(onSuccessSpy).toHaveBeenNthCalledWith( - 1, - { message: "Success" }, - { body: { message: "Test", replied_at: 123456789 } }, - undefined, - ); + // Verify onSuccess was called with correct data and variables + expect(onSuccessSpy).toHaveBeenCalledTimes(1); + const [data, variables] = onSuccessSpy.mock.calls[0]; + expect(data).toEqual({ message: "Success" }); + expect(variables).toEqual({ body: { message: "Test", replied_at: 123456789 } }); // Verify that mutationKey is part of the options object expect(options).toEqual(expect.objectContaining({ mutationKey: ["put", "/comment"] })); expect(onErrorSpy).not.toHaveBeenCalled();