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. diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts index 337919ac3..9e916e6fb 100644 --- a/packages/openapi-react-query/src/index.ts +++ b/packages/openapi-react-query/src/index.ts @@ -37,6 +37,8 @@ export type QueryKey< Init = MaybeOptionalInit, > = Init extends undefined ? readonly [Method, Path] : readonly [Method, Path, Init]; +export type MutationKey = readonly [Method, Path]; + export type QueryOptionsFunction>, Media extends MediaType> = < Method extends HttpMethod, Path extends PathsWithMethod, @@ -79,6 +81,75 @@ export type QueryOptionsFunction; +export type MutationOptionsFunction>, Media extends MediaType> = < + Method extends HttpMethod, + Path extends PathsWithMethod, + Init extends MaybeOptionalInit, + Response extends Required>, + Options extends Omit, "mutationKey" | "mutationFn">, +>( + method: Method, + path: Path, + options?: Options, +) => NoInfer< + Omit, "mutationFn"> & { + mutationFn: Exclude["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>, Media extends MediaType> = < Method extends HttpMethod, Path extends PathsWithMethod, @@ -166,6 +237,7 @@ export type UseMutationMethod { queryOptions: QueryOptionsFunction; + mutationOptions: MutationOptionsFunction; useQuery: UseQueryMethod; useSuspenseQuery: UseSuspenseQueryMethod; useInfiniteQuery: UseInfiniteQueryMethod; @@ -204,6 +276,22 @@ 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, @@ -214,8 +302,15 @@ export default function createClient = (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), useSuspenseQuery: (method, path, ...[init, options, queryClient]) => @@ -256,16 +351,7 @@ 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, diff --git a/packages/openapi-react-query/test/index.test.tsx b/packages/openapi-react-query/test/index.test.tsx index 4dcca2eee..b43ea718d 100644 --- a/packages/openapi-react-query/test/index.test.tsx +++ b/packages/openapi-react-query/test/index.test.tsx @@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider, skipToken, + useMutation, useQueries, useQuery, useSuspenseQuery, @@ -10,7 +11,7 @@ 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 { afterAll, afterEach, beforeAll, describe, expect, expectTypeOf, it, vi } 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"; @@ -71,6 +72,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 +645,188 @@ 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).toHaveBeenNthCalledWith( + 1, + { message: "Success" }, + { body: { message: "Test", replied_at: 123456789 } }, + undefined, + expect.objectContaining({ + mutationKey: ["put", "/comment"], + }), + ); + expect(onErrorSpy).not.toHaveBeenCalled(); + }); + }); describe("useMutation", () => { describe("mutate", () => {