From dcc5410fc54ecedf0df3c0eda4ffa157437879f7 Mon Sep 17 00:00:00 2001 From: Sergii Date: Wed, 31 Dec 2025 07:51:51 +0100 Subject: [PATCH] feat: adds react-query automatic proxy package --- package-lock.json | 19 ++ packages/react-query-proxy/README.md | 106 +++++++ packages/react-query-proxy/package.json | 26 ++ .../src/createProxy/createProxy.spec.ts | 284 ++++++++++++++++++ .../src/createProxy/createProxy.ts | 96 ++++++ 5 files changed, 531 insertions(+) create mode 100644 packages/react-query-proxy/README.md create mode 100644 packages/react-query-proxy/package.json create mode 100644 packages/react-query-proxy/src/createProxy/createProxy.spec.ts create mode 100644 packages/react-query-proxy/src/createProxy/createProxy.ts diff --git a/package-lock.json b/package-lock.json index ba12d31aa..8ca6feba6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8511,6 +8511,10 @@ "resolved": "apps/provider-proxy", "link": true }, + "node_modules/@akashnetwork/react-query-proxy": { + "resolved": "packages/react-query-proxy", + "link": true + }, "node_modules/@akashnetwork/react-query-sdk": { "resolved": "packages/react-query-sdk", "link": true @@ -57932,6 +57936,21 @@ "jotai": "^2.9.2" } }, + "packages/react-query-proxy": { + "name": "@akashnetwork/react-query-proxy", + "version": "1.0.0", + "license": "Apache-2.0", + "dependencies": { + "@tanstack/react-query": "^5.67.2" + }, + "devDependencies": { + "@akashnetwork/dev-config": "*", + "jest": "^29.7.0", + "prettier": "^3.3.0", + "ts-jest": "^29.4.0", + "typescript": "~5.8.2" + } + }, "packages/react-query-sdk": { "name": "@akashnetwork/react-query-sdk", "version": "1.0.0", diff --git a/packages/react-query-proxy/README.md b/packages/react-query-proxy/README.md new file mode 100644 index 000000000..8e278c948 --- /dev/null +++ b/packages/react-query-proxy/README.md @@ -0,0 +1,106 @@ +# React Query Proxy + +> Wrap any async service into fully-typed TanStack React Query hooks at runtime. +> Works with REST, gRPC, XML-RPC, generated clients, or hand-written APIs β€” no codegen required. + +--- + +## ✨ Why? + +Most teams already have an API client / SDK: +- generated from OpenAPI / gRPC +- written by hand +- shared between frontend, backend, scripts, and tests + +This library lets you **reuse that SDK directly in React** by turning it into: +- `useQuery` hooks +- `useMutation` hooks +- stable, predictable query keys + +All **at runtime**, without generating code or schemas. + +--- + +## πŸ“¦ Installation + +```bash +npm install @akashnetwork/react-query-proxy +``` + +> Peer dependencies: +> - `react >= 18` +> - `@tanstack/react-query >= 5` + +--- + +## πŸš€ Basic usage + +### 1. Start with a plain async SDK + +```ts +const sdk = { + alerts: { + async list(input?: { page?: number }) { + return fetchAlerts(input) + }, + + async create(input: { name: string }) { + return createAlert(input) + }, + }, +} +``` + +--- + +### 2. Wrap it with `createProxy` + +```ts +import { createProxy } from '@akashnetwork/react-query-proxy' + +export const api = createProxy(sdk) +``` + +--- + +### 3. Use queries + +```tsx +function AlertsList() { + const q = api.alerts.list.useQuery({ page: 1 }) + + if (q.isLoading) return
Loading…
+ if (q.isError) return
Error
+ + return
{JSON.stringify(q.data, null, 2)}
+} +``` + +--- + +### 4. Use mutations + +```tsx +function CreateAlert() { + const m = api.alerts.create.useMutation() + + return ( + + ) +} +``` + +--- + +## πŸ”‘ Query keys & invalidation + +```ts +api.alerts.list.getKey({ page: 1 }) +// β†’ ['alerts', 'list', { page: 1 }] +``` + +## πŸ“„ License + +Apache 2.0 diff --git a/packages/react-query-proxy/package.json b/packages/react-query-proxy/package.json new file mode 100644 index 000000000..6b3fcac8b --- /dev/null +++ b/packages/react-query-proxy/package.json @@ -0,0 +1,26 @@ +{ + "name": "@akashnetwork/react-query-proxy", + "version": "1.0.0", + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/akash-network/console" + }, + "license": "Apache-2.0", + "author": "Akash Network", + "main": "src/index.ts", + "scripts": { + "test": "jest", + "validate:types": "tsc --noEmit && echo" + }, + "dependencies": { + "@tanstack/react-query": "^5.67.2" + }, + "devDependencies": { + "@akashnetwork/dev-config": "*", + "jest": "^29.7.0", + "prettier": "^3.3.0", + "ts-jest": "^29.4.0", + "typescript": "~5.8.2" + } +} diff --git a/packages/react-query-proxy/src/createProxy/createProxy.spec.ts b/packages/react-query-proxy/src/createProxy/createProxy.spec.ts new file mode 100644 index 000000000..7eb8644ec --- /dev/null +++ b/packages/react-query-proxy/src/createProxy/createProxy.spec.ts @@ -0,0 +1,284 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; + +import { createProxy } from "./createProxy"; + +jest.mock("@tanstack/react-query", () => ({ + useQuery: jest.fn(), + useMutation: jest.fn() +})); + +const useQueryMock = useQuery as jest.Mock; +const useMutationMock = useMutation as jest.Mock; + +interface User { + id: number; + name?: string; +} + +interface Sdk { + users: { + list: (input?: { page?: number }) => Promise; + getById: (input: { id: number }) => Promise; + create: (input: { name: string }) => Promise; + }; + admin: { + settings: { + update: (input: { theme: string }) => Promise<{ success: boolean }>; + }; + }; +} + +describe(createProxy.name, () => { + beforeEach(() => { + jest.clearAllMocks(); + useQueryMock.mockReturnValue({ data: undefined, isLoading: true }); + useMutationMock.mockReturnValue({ mutate: jest.fn(), isPending: false }); + }); + + describe("getKey", () => { + it("returns path segments with input when input is provided", () => { + const { proxy } = setup(); + const key = proxy.users.getById.getKey({ id: 123 }); + expect(key).toEqual(["users", { id: 123 }]); + }); + + it("returns path segments without input when input is undefined", () => { + const { proxy } = setup(); + const key = proxy.users.list.getKey(undefined); + expect(key).toEqual(["users"]); + }); + + it("returns path segments without input when input is null", () => { + const { proxy } = setup(); + const key = proxy.users.list.getKey(null as unknown as undefined); + expect(key).toEqual(["users"]); + }); + }); + + describe("useQuery", () => { + it("calls useQuery with correct queryKey and queryFn", () => { + const { proxy, sdk } = setup(); + proxy.users.getById.useQuery({ id: 42 }); + + expect(useQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ["users", { id: 42 }] + }) + ); + + const queryFn = useQueryMock.mock.calls[0][0].queryFn; + queryFn(); + expect(sdk.users.getById).toHaveBeenCalledWith({ id: 42 }); + }); + + it("appends custom queryKey to the generated key", () => { + const { proxy } = setup(); + proxy.users.getById.useQuery({ id: 1 }, { queryKey: ["extra", "key"] }); + + expect(useQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ["users", { id: 1 }, "extra", "key"] + }) + ); + }); + + it("passes through additional options to useQuery", () => { + const { proxy } = setup(); + proxy.users.getById.useQuery({ id: 1 }, { enabled: false, staleTime: 5000 }); + + expect(useQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: false, + staleTime: 5000 + }) + ); + }); + + it("handles undefined input for optional parameters", () => { + const { proxy, sdk } = setup(); + proxy.users.list.useQuery(undefined); + + expect(useQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ["users"] + }) + ); + + const queryFn = useQueryMock.mock.calls[0][0].queryFn; + queryFn(); + expect(sdk.users.list).toHaveBeenCalledWith(undefined); + }); + }); + + describe("useMutation", () => { + it("calls useMutation with correct mutationKey and mutationFn", () => { + const { proxy, sdk } = setup(); + proxy.users.create.useMutation(); + + expect(useMutationMock).toHaveBeenCalledWith( + expect.objectContaining({ + mutationKey: ["users"] + }) + ); + + const mutationFn = useMutationMock.mock.calls[0][0].mutationFn; + mutationFn({ name: "John" }); + expect(sdk.users.create).toHaveBeenCalledWith({ name: "John" }); + }); + + it("appends custom mutationKey to the generated key", () => { + const { proxy } = setup(); + proxy.users.create.useMutation({ mutationKey: ["custom"] }); + + expect(useMutationMock).toHaveBeenCalledWith( + expect.objectContaining({ + mutationKey: ["users", "custom"] + }) + ); + }); + + it("passes through additional options to useMutation", () => { + const onSuccess = jest.fn(); + const { proxy } = setup(); + proxy.users.create.useMutation({ onSuccess }); + + expect(useMutationMock).toHaveBeenCalledWith( + expect.objectContaining({ + onSuccess + }) + ); + }); + }); + + describe("nested objects", () => { + it("creates proxy for deeply nested methods", () => { + const { proxy, sdk } = setup(); + proxy.admin.settings.update.useQuery({ theme: "dark" }); + + expect(useQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ["admin", "settings", { theme: "dark" }] + }) + ); + + const queryFn = useQueryMock.mock.calls[0][0].queryFn; + queryFn(); + expect(sdk.admin.settings.update).toHaveBeenCalledWith({ theme: "dark" }); + }); + + it("generates correct getKey for nested methods", () => { + const { proxy } = setup(); + const key = proxy.admin.settings.update.getKey({ theme: "light" }); + expect(key).toEqual(["admin", "settings", { theme: "light" }]); + }); + }); + + describe("caching", () => { + it("returns the same proxy instance for the same object", () => { + const sdk = createSdk(); + const proxy1 = createProxy(sdk); + const proxy2 = createProxy(sdk); + + expect(proxy1).toBe(proxy2); + }); + + it("returns the same nested proxy for repeated access", () => { + const { proxy } = setup(); + const users1 = proxy.users; + const users2 = proxy.users; + + expect(users1).toBe(users2); + }); + + it("returns the same hooks object for repeated method access", () => { + const { proxy } = setup(); + const getById1 = proxy.users.getById; + const getById2 = proxy.users.getById; + + expect(getById1).toBe(getById2); + }); + }); + + describe("inputToKey option", () => { + it("uses custom inputToKey function for query keys", () => { + const sdk = createSdk(); + const proxy = createProxy(sdk, { + inputToKey: (input: unknown) => { + if (input && typeof input === "object" && "id" in input) { + return [(input as { id: number }).id]; + } + return []; + } + }); + + const key = proxy.users.getById.getKey({ id: 99 }); + expect(key).toEqual(["users", 99]); + }); + + it("applies custom inputToKey in useQuery", () => { + const sdk = createSdk(); + const proxy = createProxy(sdk, { + inputToKey: (input: unknown) => (input ? [JSON.stringify(input)] : []) + }); + + proxy.users.getById.useQuery({ id: 5 }); + + expect(useQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ["users", '{"id":5}'] + }) + ); + }); + }); + + describe("edge cases", () => { + it("returns undefined for non-existent properties", () => { + const { proxy } = setup(); + expect((proxy as Record).nonExistent).toBeUndefined(); + }); + + it("returns primitive values as-is", () => { + const sdk = { + version: "1.0.0", + count: 42 + }; + const proxy = createProxy(sdk); + + expect(proxy.version).toBe("1.0.0"); + expect(proxy.count).toBe(42); + }); + + it("handles null values in object", () => { + const sdk = { + nullValue: null as null, + users: { + list: jest.fn() + } + }; + const proxy = createProxy(sdk); + + expect(proxy.nullValue).toBeNull(); + }); + }); + + function setup() { + const sdk = createSdk(); + const proxy = createProxy(sdk); + return { sdk, proxy }; + } + + function createSdk(): Sdk { + return { + users: { + list: jest.fn().mockResolvedValue([]), + getById: jest.fn().mockResolvedValue({ id: 1 }), + create: jest.fn().mockResolvedValue({ id: 1 }) + }, + admin: { + settings: { + update: jest.fn().mockResolvedValue({ success: true }) + } + } + }; + } +}); diff --git a/packages/react-query-proxy/src/createProxy/createProxy.ts b/packages/react-query-proxy/src/createProxy/createProxy.ts new file mode 100644 index 000000000..0a48cefe5 --- /dev/null +++ b/packages/react-query-proxy/src/createProxy/createProxy.ts @@ -0,0 +1,96 @@ +import type { QueryKey, UseMutationOptions, UseMutationResult, UseQueryOptions, UseQueryResult } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; + +export function createProxy>(object: T, proxyOptions?: CreateProxyOptions): RecursiveHooksProxy { + return createRecursiveProxyImpl(object, proxyOptions); +} + +export interface CreateProxyOptions { + inputToKey?: (input: unknown) => PropertyKey[]; +} + +const proxyCache = new WeakMap>(); +const defaultInputToKey = (input: unknown): unknown[] => (input == null ? [] : [input]); + +function createRecursiveProxyImpl>( + object: T, + proxyOptions?: CreateProxyOptions, + fullPath: PropertyKey[] = [] +): RecursiveHooksProxy { + if (!proxyCache.has(object)) { + proxyCache.set(object, new Map()); + } + + const inputToKey = proxyOptions?.inputToKey ?? defaultInputToKey; + const valueByPath = proxyCache.get(object)!; + const stringifiedPath = fullPath.join("."); + if (!valueByPath[stringifiedPath]) { + valueByPath[stringifiedPath] = new Proxy(object, { + get(target, prop) { + if (!Object.hasOwn(target, prop)) return undefined; + + const value = (target as any)[prop]; + if ((typeof value !== "function" && typeof value !== "object") || value === null) { + return value; + } + + if (typeof value === "function") { + const key = `${stringifiedPath}.${prop as string}`; + const getKey = (input: unknown) => fullPath.concat(inputToKey(input) as PropertyKey[]); + valueByPath[key] ??= { + getKey, + useQuery: (input, options) => { + const queryKey = getKey(input); + if (options?.queryKey) { + queryKey.push(...(options.queryKey as PropertyKey[])); + } + return useQuery({ + ...options, + queryKey, + queryFn: () => (target as any)[prop](input) + }); + }, + useMutation: options => { + const mutationKey = fullPath; + if (options?.mutationKey) { + mutationKey.push(...(options.mutationKey as PropertyKey[])); + } + return useMutation({ + ...options, + mutationKey, + mutationFn: input => (target as any)[prop](input) + }); + } + } satisfies HooksProxy; + return valueByPath[key]; + } + + return createRecursiveProxyImpl(value, proxyOptions, fullPath.concat(prop)); + } + }); + } + + return valueByPath[stringifiedPath]; +} + +type RecursiveHooksProxy = { + [K in keyof T]: T[K] extends (...args: any[]) => any ? HooksProxy : RecursiveHooksProxy; +}; + +type HooksProxy any> = undefined extends Parameters[0] + ? { + getKey: (input?: undefined) => PropertyKey[]; + useQuery: ( + input?: undefined, + options?: Omit, Error, any, QueryKey>, "queryFn" | "queryKey"> & { queryKey?: QueryKey } + ) => UseQueryResult>>; + useMutation: (options?: Omit, Error, any, any>, "mutationFn">) => UseMutationResult>>; + } + : { + getKey: (input: Parameters[0]) => PropertyKey[]; + useQuery: ( + input: Parameters[0], + options?: Omit, Error, any, QueryKey>, "queryFn" | "queryKey"> & { queryKey?: QueryKey } + ) => UseQueryResult>>; + useMutation: (options?: Omit, Error, any, any>, "mutationFn">) => UseMutationResult>>; + };