From ea12536021bcc86dce9c3e6bb874417d66e5727d Mon Sep 17 00:00:00 2001 From: minchodang Date: Mon, 6 Oct 2025 15:29:17 +0900 Subject: [PATCH 1/4] 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 8995cbfa0f038e98af36390d4949353023642ba1 Mon Sep 17 00:00:00 2001 From: minchodang Date: Mon, 6 Oct 2025 15:43:27 +0900 Subject: [PATCH 2/4] 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/4] 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 ec187d023ff3be704c2f05244796cc21d747d985 Mon Sep 17 00:00:00 2001 From: minchodang Date: Mon, 6 Oct 2025 17:42:58 +0900 Subject: [PATCH 4/4] 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(); });