diff --git a/.changeset/spicy-monkeys-battle.md b/.changeset/spicy-monkeys-battle.md new file mode 100644 index 000000000..33f182d44 --- /dev/null +++ b/.changeset/spicy-monkeys-battle.md @@ -0,0 +1,5 @@ +--- +"openapi-react-query": minor +--- + +Add typesafe setQueryData to OpenapiQueryClient diff --git a/docs/.vitepress/en.ts b/docs/.vitepress/en.ts index b70514df7..3028755b5 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: "setQueryData", link: "/set-query-data" }, ], }, { diff --git a/docs/openapi-react-query/set-query-data.md b/docs/openapi-react-query/set-query-data.md new file mode 100644 index 000000000..ca025a122 --- /dev/null +++ b/docs/openapi-react-query/set-query-data.md @@ -0,0 +1,98 @@ +--- +title: setQueryData +--- + +# {{ $frontmatter.title }} + +The `setQueryData` method lets you directly update the cached data for a specific query (by method, path, and params) in a type-safe way, just like [QueryClient.setQueryData](https://tanstack.com/query/latest/docs/reference/QueryClient#queryclientsetquerydata). + +- The updater function is fully type-safe and infers the correct data type from your OpenAPI schema. +- No manual type annotations are needed for the updater argument. +- Works with any query created by this client. + +::: tip +You can find more information about `setQueryData` on the [@tanstack/react-query documentation](https://tanstack.com/query/latest/docs/reference/QueryClient#queryclientsetquerydata). +::: + +## Example + +::: code-group + +```tsx [src/app.tsx] +import { $api } from './api' + +export const App = () => { + // Update the cached list of posts. + const handleAddPost = (newPost) => { + $api.setQueryData( + 'get', + '/posts', + (oldPosts = []) => [...oldPosts, newPost], + queryClient + ) + } + + // Update a single post by id. + const handleEditPost = (updatedPost) => { + $api.setQueryData( + 'get', + '/posts/{post_id}', + (oldPost) => ({ ...oldPost, ...updatedPost }), + queryClient, + { params: { path: { post_id: updatedPost.id } } } + ) + } + + return null +} +``` + +```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) +``` + +::: + +## Type Safety Example + +If you try to update the cache with the wrong type, TypeScript will show an error: + +```ts [❌ TypeScript Error Example] +$api.setQueryData( + 'get', + '/posts', + // ❌ This updater returns a string, but the cached data should be an array of posts. + (oldPosts) => 'not an array', // TypeScript Error: Type 'string' is not assignable to type 'Post[]'. + queryClient +) +``` + +## Api + +```tsx +$api.setQueryData(method, path, updater, queryClient, options?); +``` + +**Arguments** + +- `method` **(required)** + - The HTTP method for the query (e.g. "get"). +- `path` **(required)** + - The path for the query (e.g. "/posts/{post_id}"). +- `updater` **(required)** + - A function that receives the current cached data and returns the new data. The argument is fully type-inferred from your OpenAPI schema. +- `queryClient` **(required)** + - The [QueryClient](https://tanstack.com/query/latest/docs/framework/react/reference/QueryClient) instance to update. +- `options` + - The fetch options (e.g. params) for the query. Only required if your endpoint requires parameters. + +## TypeScript Inference + +The `updater` function argument is fully type-inferred from your OpenAPI schema, so you get autocompletion and type safety for the cached data. No manual type annotations are needed. diff --git a/packages/openapi-react-query/biome.json b/packages/openapi-react-query/biome.json index d5bf28ca0..f809ca5ef 100644 --- a/packages/openapi-react-query/biome.json +++ b/packages/openapi-react-query/biome.json @@ -2,7 +2,7 @@ "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", "extends": ["../../biome.json"], "files": { - "ignore": ["./test/fixtures/"] + "ignore": ["./test/fixtures/", "./examples/setquerydata-example/api.d.ts"] }, "linter": { "rules": { diff --git a/packages/openapi-react-query/examples/setquerydata-example/README.md b/packages/openapi-react-query/examples/setquerydata-example/README.md new file mode 100644 index 000000000..34f8cf570 --- /dev/null +++ b/packages/openapi-react-query/examples/setquerydata-example/README.md @@ -0,0 +1,21 @@ +# setQueryData Example + +This example demonstrates the type-safe `setQueryData` functionality in `openapi-react-query`. + +## Overview + +The `setQueryData` method allows you to update cached query data with full TypeScript type safety. The updater function receives the current cached data and must return data of the same type, ensuring type consistency. + +## Setup + +1. Generate TypeScript types from the OpenAPI spec: + +```bash +npm run generate-types +``` + +1. Run TypeScript check to see type safety in action: + +```bash +npm run type-check +``` diff --git a/packages/openapi-react-query/examples/setquerydata-example/api.d.ts b/packages/openapi-react-query/examples/setquerydata-example/api.d.ts new file mode 100644 index 000000000..d4481482f --- /dev/null +++ b/packages/openapi-react-query/examples/setquerydata-example/api.d.ts @@ -0,0 +1,141 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/posts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get all posts */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Post"][]; + }; + }; + }; + }; + put?: never; + /** Create a new post */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PostInput"]; + }; + }; + responses: { + /** @description Post created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Post"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/posts/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a single post */ + get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Post"]; + }; + }; + /** @description Post not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + Post: { + id: string; + title: string; + content: string; + /** Format: date-time */ + createdAt?: string; + }; + PostInput: { + title: string; + content: string; + }; + Error: { + code: number; + message: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export type operations = Record; diff --git a/packages/openapi-react-query/examples/setquerydata-example/demo.ts b/packages/openapi-react-query/examples/setquerydata-example/demo.ts new file mode 100644 index 000000000..6491c5ffc --- /dev/null +++ b/packages/openapi-react-query/examples/setquerydata-example/demo.ts @@ -0,0 +1,66 @@ +// Example demonstrating setQueryData with type safety +import createFetchClient from "openapi-fetch"; +import createClient from "../../src"; +// NOTE: If running locally, ensure you import from the local source, not the package name, to avoid module resolution errors. +import { QueryClient } from "@tanstack/react-query"; +import type { paths, components } from "./api"; + +type Post = components["schemas"]["Post"]; + +// Create clients +const fetchClient = createFetchClient({ + baseUrl: "https://api.example.com", +}); +const $api = createClient(fetchClient); +const queryClient = new QueryClient(); + +// Simulate creating a new post +const newPost: Post = { + id: "123", + title: "New Post", + content: "Post content", + createdAt: new Date().toISOString(), +}; + +// Example 1: Update posts list with updater function +async function createPostAndUpdateCache() { + $api.setQueryData( + "get", + "/posts", + (oldPosts) => { + return oldPosts ? [...oldPosts, newPost] : [newPost]; + }, + queryClient, + {}, + ); + + return newPost; +} + +// Example 2: Update a single post after editing +async function updatePostAndUpdateCache(postId: string, updates: { title?: string; content?: string }) { + // Update the specific post cache with updater function + // This should now error: missing required init (id) + $api.setQueryData( + "get", + "/posts/{id}", + (oldPost) => { + if (!oldPost) { + throw new Error("No post in cache"); + } + return { ...oldPost, ...updates }; + }, + queryClient, + { params: { path: { id: postId } } }, + ); + + // This should be fine: /posts does not require init + $api.setQueryData("get", "/posts", (oldPosts) => oldPosts || [], queryClient); +} + +// Example 3: Directly set the data +function clearPostsCache() { + $api.setQueryData("get", "/posts", [newPost], queryClient, {}); +} + +export { createPostAndUpdateCache, updatePostAndUpdateCache, clearPostsCache }; diff --git a/packages/openapi-react-query/examples/setquerydata-example/openapi.yaml b/packages/openapi-react-query/examples/setquerydata-example/openapi.yaml new file mode 100644 index 000000000..6415cdc87 --- /dev/null +++ b/packages/openapi-react-query/examples/setquerydata-example/openapi.yaml @@ -0,0 +1,92 @@ +openapi: 3.0.0 +info: + title: Blog API + version: 1.0.0 +paths: + /posts: + get: + summary: Get all posts + responses: + "200": + description: Successful response + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Post" + post: + summary: Create a new post + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PostInput" + responses: + "201": + description: Post created + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + /posts/{id}: + get: + summary: Get a single post + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Successful response + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + "404": + description: Post not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" +components: + schemas: + Post: + type: object + required: + - id + - title + - content + properties: + id: + type: string + title: + type: string + content: + type: string + createdAt: + type: string + format: date-time + PostInput: + type: object + required: + - title + - content + properties: + title: + type: string + content: + type: string + Error: + type: object + required: + - code + - message + properties: + code: + type: number + message: + type: string diff --git a/packages/openapi-react-query/examples/setquerydata-example/package.json b/packages/openapi-react-query/examples/setquerydata-example/package.json new file mode 100644 index 000000000..a707527a2 --- /dev/null +++ b/packages/openapi-react-query/examples/setquerydata-example/package.json @@ -0,0 +1,20 @@ +{ + "name": "openapi-react-query-setquerydata-example", + "version": "1.0.0", + "description": "Example demonstrating setQueryData functionality with type safety", + "scripts": { + "generate-types": "openapi-typescript ./openapi.yaml -o ./api.d.ts", + "type-check": "tsc --noEmit", + "build": "tsc" + }, + "dependencies": { + "@tanstack/react-query": "^5.84.2", + "openapi-fetch": "workspace:^", + "openapi-react-query": "workspace:^" + }, + "devDependencies": { + "@types/node": "^22.17.1", + "openapi-typescript": "workspace:^", + "typescript": "^5.9.2" + } +} diff --git a/packages/openapi-react-query/examples/setquerydata-example/tsconfig.json b/packages/openapi-react-query/examples/setquerydata-example/tsconfig.json new file mode 100644 index 000000000..0ab34cf27 --- /dev/null +++ b/packages/openapi-react-query/examples/setquerydata-example/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "dist", + "skipLibCheck": true, + "sourceRoot": ".", + "jsx": "react-jsx", + "target": "ES2022", + "types": ["vitest/globals"] + }, + "include": ["src", "test", "*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts index af4eec6c9..e1931cc25 100644 --- a/packages/openapi-react-query/src/index.ts +++ b/packages/openapi-react-query/src/index.ts @@ -11,6 +11,7 @@ import { type QueryClient, type QueryFunctionContext, type SkipToken, + type Updater, useMutation, useQuery, useSuspenseQuery, @@ -164,12 +165,26 @@ export type UseMutationMethod UseMutationResult; +export type SetQueryDataMethod>, Media extends MediaType> = < + Method extends HttpMethod, + Path extends PathsWithMethod, + Init extends MaybeOptionalInit, + Data extends Required>["data"], +>( + method: Method, + path: Path, + updater: Updater, + queryClient: QueryClient, + ...init: RequiredKeysOf extends never ? [InitWithUnknowns?] : [InitWithUnknowns] +) => void; + export interface OpenapiQueryClient { queryOptions: QueryOptionsFunction; useQuery: UseQueryMethod; useSuspenseQuery: UseSuspenseQueryMethod; useInfiniteQuery: UseInfiniteQueryMethod; useMutation: UseMutationMethod; + setQueryData: SetQueryDataMethod; } export type MethodResponse< @@ -193,7 +208,10 @@ export default function createClient>) => { const mth = method.toUpperCase() as Uppercase; const fn = client[mth] as ClientMethod; - const { data, error, response } = await fn(path, { signal, ...(init as any) }); // TODO: find a way to avoid as any + const { data, error, response } = await fn(path, { + signal, + ...(init as any), + }); // TODO: find a way to avoid as any if (error) { throw error; } @@ -270,5 +288,8 @@ export default function createClient { expect(client).toHaveProperty("useQuery"); expect(client).toHaveProperty("useSuspenseQuery"); expect(client).toHaveProperty("useMutation"); + expect(client).toHaveProperty("setQueryData"); }); describe("queryOptions", () => { @@ -124,7 +125,10 @@ describe("client", () => { }); it("returns query options that can be passed to useQueries", async () => { - const fetchClient = createFetchClient({ baseUrl, fetch: fetchInfinite }); + const fetchClient = createFetchClient({ + baseUrl, + fetch: fetchInfinite, + }); const client = createClient(fetchClient); const { result } = renderHook( @@ -1202,4 +1206,177 @@ describe("client", () => { expect(result.current.data).toEqual([1, 2, 3, 4, 5, 6]); }); }); + + describe("setQueryData", () => { + it("should set query data with type safety", async () => { + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient); + + const initialData = { title: "Initial Title", body: "Initial Body" }; + const updatedData = { title: "Updated Title", body: "Updated Body" }; + + // Set initial data + queryClient.setQueryData( + client.queryOptions("get", "/blogposts/{post_id}", { + params: { path: { post_id: "1" } }, + }).queryKey, + initialData, + ); + + // Update data using setQueryData + client.setQueryData( + "get", + "/blogposts/{post_id}", + (oldData) => { + expectTypeOf(oldData).toEqualTypeOf< + | { + title: string; + body: string; + publish_date?: number; + } + | undefined + >(); + return updatedData; + }, + queryClient, + { + params: { path: { post_id: "1" } }, + }, + ); + + // Verify data was updated + const cachedData = queryClient.getQueryData( + client.queryOptions("get", "/blogposts/{post_id}", { + params: { path: { post_id: "1" } }, + }).queryKey, + ); + + expect(cachedData).toEqual(updatedData); + }); + + it("should work with queries without init params", async () => { + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient); + + const initialData = ["item1", "item2"]; + const updatedData = ["updated1", "updated2"]; + + // Set initial data + queryClient.setQueryData(client.queryOptions("get", "/string-array").queryKey, initialData); + + // Update data using setQueryData + client.setQueryData( + "get", + "/string-array", + (oldData) => { + expectTypeOf(oldData).toEqualTypeOf(); + return updatedData; + }, + queryClient, + ); + + // Verify data was updated + const cachedData = queryClient.getQueryData(client.queryOptions("get", "/string-array").queryKey); + expect(cachedData).toEqual(updatedData); + }); + + it("should error if you don't pass init params when required", () => { + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient); + + // For /blogposts/{post_id}, init params are required (post_id in path) + // @ts-expect-error - should error because init params are required + client.setQueryData("get", "/blogposts/{post_id}", (oldData) => oldData, queryClient); + }); + + it("should use provided custom queryClient", async () => { + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient); + const customQueryClient = new QueryClient({}); + + const initialData = { title: "Initial", body: "Body" }; + const updatedData = { title: "Updated", body: "Body" }; + + // Set initial data in custom client + customQueryClient.setQueryData( + client.queryOptions("get", "/blogposts/{post_id}", { + params: { path: { post_id: "1" } }, + }).queryKey, + initialData, + ); + + // Update data using setQueryData with custom client + client.setQueryData("get", "/blogposts/{post_id}", () => updatedData, customQueryClient, { + params: { path: { post_id: "1" } }, + }); + + // Verify data was updated in custom client + const cachedData = customQueryClient.getQueryData( + client.queryOptions("get", "/blogposts/{post_id}", { + params: { path: { post_id: "1" } }, + }).queryKey, + ); + + expect(cachedData).toEqual(updatedData); + + // Verify main client was not affected + const mainCachedData = queryClient.getQueryData( + client.queryOptions("get", "/blogposts/{post_id}", { + params: { path: { post_id: "1" } }, + }).queryKey, + ); + + expect(mainCachedData).toBeUndefined(); + }); + + it("should allow setting data directly (not just via updater function)", () => { + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient); + + const initialData = ["item1", "item2"]; + const newData = ["updated1", "updated2"]; + + // Set initial data + queryClient.setQueryData(client.queryOptions("get", "/string-array").queryKey, initialData); + + // Set data directly using setQueryData (not a function) + client.setQueryData("get", "/string-array", newData, queryClient); + + // Verify data was updated + const cachedData = queryClient.getQueryData(client.queryOptions("get", "/string-array").queryKey); + expect(cachedData).toEqual(newData); + }); + + it("should error if you set data with the wrong type directly", () => { + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient); + // @ts-expect-error - should not allow setting a string for a string array query. + client.setQueryData("get", "/string-array", "not an array", queryClient, { + params: { path: { post_id: "1" } }, + }); + + // @ts-expect-error - should not allow setting an object with the wrong type for a string array query. + client.setQueryData("get", "/string-array", { wrong: "data" }, queryClient, { + params: { path: { post_id: "1" } }, + }); + }); + + it("should enforce type safety on updater function return type", () => { + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient); + + client.setQueryData( + "get", + "/blogposts/{post_id}", + // @ts-expect-error - Return type is not the same as the expected type. + () => { + return { invalidField: "invalid" }; + }, + queryClient, + { + params: { path: { post_id: "1" } }, + }, + ); + }); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8a37f712..d04c662ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -260,6 +260,28 @@ importers: specifier: ^6.0.0 version: 6.0.0(react@18.3.1) + packages/openapi-react-query/examples/setquerydata-example: + dependencies: + '@tanstack/react-query': + specifier: ^5.84.2 + version: 5.84.2(react@18.3.1) + openapi-fetch: + specifier: workspace:^ + version: link:../../../openapi-fetch + openapi-react-query: + specifier: workspace:^ + version: link:../.. + devDependencies: + '@types/node': + specifier: ^22.17.1 + version: 22.17.1 + openapi-typescript: + specifier: workspace:^ + version: link:../../../openapi-typescript + typescript: + specifier: ^5.9.2 + version: 5.9.2 + packages/openapi-typescript: dependencies: '@redocly/openapi-core':