feat(ui): adopt openapi-react-query as the typed data-fetching client

Stands up the typed transport on top of the generated OpenAPI types and migrates
the first real hook to prove the pattern. src/lib/http/api.ts creates an
openapi-fetch client wrapped by openapi-react-query ($api); base URL stays
call-time via getProxyBaseUrl in an onRequest middleware (not a frozen
createClient baseUrl, which would break server-root-path and worker switching),
and the access token is passed per call via authHeader.

useInfiniteUsers now calls $api.useInfiniteQuery("get", "/user/list", ...) instead
of the hand-rolled userListCall: query params, the response body, and the user
rows are all typed from the spec, and the 11-arg positional call collapses into a
typed query object. Its infinite-query return shape is unchanged, so UsagePageView
is untouched. Global query errors route through QueryCache.onError into the
existing handleError, preserving the session-expiry logout behavior.

The test mocks fetch (installed via vi.hoisted because openapi-fetch captures
globalThis.fetch at client creation) and asserts the real request URLs, the bearer
header, pagination, and the error path.
This commit is contained in:
ryan-crabbe-berri 2026-06-06 19:47:06 -07:00
parent 776b272689
commit ffa3880b80
6 changed files with 190 additions and 229 deletions

View file

@ -24,6 +24,8 @@
"moment": "2.30.1",
"next": "16.2.6",
"openai": "4.104.0",
"openapi-fetch": "0.17.0",
"openapi-react-query": "0.5.4",
"papaparse": "5.5.3",
"react": "18.3.1",
"react-copy-to-clipboard": "5.1.1",
@ -10005,6 +10007,28 @@
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"license": "MIT"
},
"node_modules/openapi-fetch": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz",
"integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==",
"license": "MIT",
"dependencies": {
"openapi-typescript-helpers": "^0.1.0"
}
},
"node_modules/openapi-react-query": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/openapi-react-query/-/openapi-react-query-0.5.4.tgz",
"integrity": "sha512-V9lRiozjHot19/BYSgXYoyznDxDJQhEBSdi26+SJ0UqjMANLQhkni4XG+Z7e3Ag7X46ZLMrL9VxYkghU3QvbWg==",
"license": "MIT",
"dependencies": {
"openapi-typescript-helpers": "^0.1.0"
},
"peerDependencies": {
"@tanstack/react-query": "^5.80.0",
"openapi-fetch": "^0.17.0"
}
},
"node_modules/openapi-typescript": {
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz",
@ -10026,6 +10050,12 @@
"typescript": "^5.x"
}
},
"node_modules/openapi-typescript-helpers": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz",
"integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==",
"license": "MIT"
},
"node_modules/openapi-typescript/node_modules/supports-color": {
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",

View file

@ -40,6 +40,8 @@
"moment": "2.30.1",
"next": "16.2.6",
"openai": "4.104.0",
"openapi-fetch": "0.17.0",
"openapi-react-query": "0.5.4",
"papaparse": "5.5.3",
"react": "18.3.1",
"react-copy-to-clipboard": "5.1.1",

View file

@ -3,21 +3,24 @@ import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React, { ReactNode } from "react";
import { useInfiniteUsers } from "./useUsers";
import { userListCall } from "@/components/networking";
import type { UserListResponse } from "@/components/networking";
import type { paths } from "@/lib/http/schema";
type UserListResponse = paths["/user/list"]["get"]["responses"][200]["content"]["application/json"];
// openapi-fetch captures globalThis.fetch when the client is created (at import time), so the
// mock must be installed before imports run — hence vi.hoisted. Per test we reconfigure its
// implementation; the captured reference stays the same.
const { fetchMock } = vi.hoisted(() => {
const fetchMock = vi.fn();
globalThis.fetch = fetchMock as unknown as typeof fetch;
return { fetchMock };
});
// api.ts only needs the base URL + auth header name from networking; mock those so the
// test doesn't pull in the whole networking module.
vi.mock("@/components/networking", () => ({
userListCall: vi.fn(),
}));
vi.mock("../common/queryKeysFactory", () => ({
createQueryKeys: vi.fn((resource: string) => ({
all: [resource],
lists: () => [resource, "list"],
list: (params?: any) => [resource, "list", { params }],
details: () => [resource, "detail"],
detail: (uid: string) => [resource, "detail", uid],
})),
getProxyBaseUrl: () => "http://localhost:4000",
getGlobalLitellmHeaderName: () => "Authorization",
}));
const mockUseAuthorized = vi.fn();
@ -36,134 +39,102 @@ const DEFAULT_AUTH = {
showSSOBanner: false,
};
const buildUserListResponse = (page: number, totalPages: number, userCount = 2): UserListResponse => ({
page,
page_size: 50,
total: totalPages * userCount,
total_pages: totalPages,
users: Array.from({ length: userCount }, (_, i) => ({
user_id: `user-${page}-${i}`,
user_email: `user-${page}-${i}@example.com`,
user_alias: null,
user_role: "Internal User",
spend: 0,
max_budget: null,
key_count: 0,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
sso_user_id: null,
budget_duration: null,
})),
});
const buildUserListResponse = (page: number, totalPages: number, userCount = 2): UserListResponse =>
({
page,
page_size: 50,
total: totalPages * userCount,
total_pages: totalPages,
users: Array.from({ length: userCount }, (_, i) => ({
user_id: `user-${page}-${i}`,
user_email: `user-${page}-${i}@example.com`,
user_role: "internal_user",
key_count: 0,
})),
}) as UserListResponse;
const requestOf = (arg: unknown): Request => arg as Request;
const jsonResponse = (body: unknown, status = 200): Response =>
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
/** Serve buildUserListResponse for whatever `page` the request asks for. */
const stubPagedFetch = (totalPages: number, userCount = 2) => {
fetchMock.mockImplementation(async (arg: unknown) => {
const url = new URL(requestOf(arg).url);
const page = Number(url.searchParams.get("page") ?? "1");
return jsonResponse(buildUserListResponse(page, totalPages, userCount));
});
return fetchMock;
};
const lastRequestUrl = (mock: typeof fetchMock): URL => new URL(requestOf(mock.mock.calls.at(-1)![0]).url);
describe("useInfiniteUsers", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
vi.clearAllMocks();
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
fetchMock.mockReset();
mockUseAuthorized.mockReset();
mockUseAuthorized.mockReturnValue(DEFAULT_AUTH);
});
const wrapper = ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
it("should return paginated user data when query is successful", async () => {
const mockResponse = buildUserListResponse(1, 2);
(userListCall as any).mockResolvedValue(mockResponse);
it("returns the first page of typed user data on success", async () => {
stubPagedFetch(2);
const { result } = renderHook(() => useInfiniteUsers(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.pages).toHaveLength(1);
expect(result.current.data?.pages[0]).toEqual(mockResponse);
expect(userListCall).toHaveBeenCalledWith("test-access-token", null, 1, 50, null);
expect(result.current.data?.pages[0].users[0].user_id).toBe("user-1-0");
});
it("should use the default page size of 50", async () => {
const mockResponse = buildUserListResponse(1, 1);
(userListCall as any).mockResolvedValue(mockResponse);
it("requests page 1 with the default page size and the bearer auth header", async () => {
const mock = stubPagedFetch(1);
const { result } = renderHook(() => useInfiniteUsers(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(userListCall).toHaveBeenCalledWith("test-access-token", null, 1, 50, null);
const url = lastRequestUrl(mock);
expect(url.pathname).toBe("/user/list");
expect(url.searchParams.get("page")).toBe("1");
expect(url.searchParams.get("page_size")).toBe("50");
expect(requestOf(mock.mock.calls[0][0]).headers.get("Authorization")).toBe("Bearer test-access-token");
});
it("should use a custom page size when provided", async () => {
const customPageSize = 25;
const mockResponse = buildUserListResponse(1, 1, 5);
(userListCall as any).mockResolvedValue(mockResponse);
it("sends a custom page size when provided", async () => {
const mock = stubPagedFetch(1, 5);
const { result } = renderHook(() => useInfiniteUsers(25), { wrapper });
const { result } = renderHook(() => useInfiniteUsers(customPageSize), {
wrapper,
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(userListCall).toHaveBeenCalledWith("test-access-token", null, 1, customPageSize, null);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(lastRequestUrl(mock).searchParams.get("page_size")).toBe("25");
});
it("should pass searchEmail to userListCall when provided", async () => {
const searchEmail = "search@example.com";
const mockResponse = buildUserListResponse(1, 1, 1);
(userListCall as any).mockResolvedValue(mockResponse);
it("maps searchEmail to the user_email query param", async () => {
const mock = stubPagedFetch(1, 1);
const { result } = renderHook(() => useInfiniteUsers(50, "search@example.com"), { wrapper });
const { result } = renderHook(() => useInfiniteUsers(50, searchEmail), {
wrapper,
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(userListCall).toHaveBeenCalledWith("test-access-token", null, 1, 50, searchEmail);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(lastRequestUrl(mock).searchParams.get("user_email")).toBe("search@example.com");
});
it("should pass null for searchEmail when not provided", async () => {
const mockResponse = buildUserListResponse(1, 1);
(userListCall as any).mockResolvedValue(mockResponse);
it("omits user_email when no searchEmail is given", async () => {
const mock = stubPagedFetch(1);
const { result } = renderHook(() => useInfiniteUsers(50), { wrapper });
const { result } = renderHook(() => useInfiniteUsers(50, undefined), {
wrapper,
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(userListCall).toHaveBeenCalledWith("test-access-token", null, 1, 50, null);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(lastRequestUrl(mock).searchParams.has("user_email")).toBe(false);
});
it("should fetch the next page when more pages are available", async () => {
const page1 = buildUserListResponse(1, 3);
const page2 = buildUserListResponse(2, 3);
let callCount = 0;
(userListCall as any).mockImplementation(async () => {
callCount++;
return callCount === 1 ? page1 : page2;
});
it("fetches the next page with an incremented page param", async () => {
const mock = stubPagedFetch(3);
const { result } = renderHook(() => useInfiniteUsers(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.hasNextPage).toBe(true);
result.current.fetchNextPage();
@ -173,115 +144,53 @@ describe("useInfiniteUsers", () => {
expect(result.current.data?.pages).toHaveLength(2);
});
expect(result.current.data?.pages[1]).toEqual(page2);
expect(userListCall).toHaveBeenCalledTimes(2);
expect(userListCall).toHaveBeenLastCalledWith("test-access-token", null, 2, 50, null);
expect(result.current.data?.pages[1].page).toBe(2);
expect(mock).toHaveBeenCalledTimes(2);
expect(lastRequestUrl(mock).searchParams.get("page")).toBe("2");
});
it("should not have a next page when on the last page", async () => {
const lastPage = buildUserListResponse(2, 2);
(userListCall as any).mockResolvedValue(lastPage);
it("has no next page on the last page", async () => {
stubPagedFetch(1);
const { result } = renderHook(() => useInfiniteUsers(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.hasNextPage).toBe(false);
});
it("should not execute query when accessToken is missing", async () => {
mockUseAuthorized.mockReturnValue({
...DEFAULT_AUTH,
accessToken: null,
});
it("surfaces an error when the request fails", async () => {
fetchMock.mockImplementation(async () => jsonResponse({ detail: "boom" }, 500));
const { result } = renderHook(() => useInfiniteUsers(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.data).toBeUndefined();
});
it.each([
["null token", { accessToken: null }],
["non-admin role", { userRole: "Internal User" }],
])("does not fire a request when gated by %s", async (_label, override) => {
const mock = stubPagedFetch(1);
mockUseAuthorized.mockReturnValue({ ...DEFAULT_AUTH, ...override });
const { result } = renderHook(() => useInfiniteUsers(), { wrapper });
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
expect(result.current.isFetched).toBe(false);
expect(userListCall).not.toHaveBeenCalled();
expect(mock).not.toHaveBeenCalled();
});
it("should not execute query when userRole is not an admin role", async () => {
mockUseAuthorized.mockReturnValue({
...DEFAULT_AUTH,
userRole: "Internal User",
});
const { result } = renderHook(() => useInfiniteUsers(), { wrapper });
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
expect(result.current.isFetched).toBe(false);
expect(userListCall).not.toHaveBeenCalled();
});
it("should not execute query when both accessToken and userRole are invalid", async () => {
mockUseAuthorized.mockReturnValue({
...DEFAULT_AUTH,
accessToken: null,
userRole: "App User",
});
const { result } = renderHook(() => useInfiniteUsers(), { wrapper });
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
expect(result.current.isFetched).toBe(false);
expect(userListCall).not.toHaveBeenCalled();
});
it("should execute query for each admin role", async () => {
const adminRoles = ["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer", "org_admin"];
for (const role of adminRoles) {
vi.clearAllMocks();
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const mockResponse = buildUserListResponse(1, 1);
(userListCall as any).mockResolvedValue(mockResponse);
mockUseAuthorized.mockReturnValue({ ...DEFAULT_AUTH, userRole: role });
it("runs for each admin role", async () => {
for (const userRole of ["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer", "org_admin"]) {
fetchMock.mockReset();
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const mock = stubPagedFetch(1);
mockUseAuthorized.mockReturnValue({ ...DEFAULT_AUTH, userRole });
const { result } = renderHook(() => useInfiniteUsers(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(userListCall).toHaveBeenCalledTimes(1);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mock).toHaveBeenCalledTimes(1);
}
});
it("should handle error when userListCall fails", async () => {
const testError = new Error("Failed to fetch users");
(userListCall as any).mockRejectedValue(testError);
const { result } = renderHook(() => useInfiniteUsers(), { wrapper });
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(testError);
expect(result.current.data).toBeUndefined();
});
it("should pass empty string searchEmail as null", async () => {
const mockResponse = buildUserListResponse(1, 1);
(userListCall as any).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useInfiniteUsers(50, ""), {
wrapper,
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(userListCall).toHaveBeenCalledWith("test-access-token", null, 1, 50, null);
});
});

View file

@ -1,38 +1,28 @@
import { userListCall, UserListResponse } from "@/components/networking";
import { useInfiniteQuery } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import { $api, authHeader } from "@/lib/http/api";
import { all_admin_roles } from "@/utils/roles";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
const infiniteUsersKeys = createQueryKeys("infiniteUsers");
const DEFAULT_PAGE_SIZE = 50;
export const useInfiniteUsers = (pageSize: number = DEFAULT_PAGE_SIZE, searchEmail?: string) => {
const { accessToken, userRole } = useAuthorized();
return useInfiniteQuery<UserListResponse>({
queryKey: infiniteUsersKeys.list({
filters: {
pageSize,
...(searchEmail && { searchEmail }),
return $api.useInfiniteQuery(
"get",
"/user/list",
{
params: {
query: {
page_size: pageSize,
...(searchEmail ? { user_email: searchEmail } : {}),
},
},
}),
queryFn: async ({ pageParam }) => {
return await userListCall(
accessToken!,
null, // userIDs
pageParam as number, // page
pageSize, // page_size
searchEmail || null, // userEmail
);
headers: authHeader(accessToken!),
},
initialPageParam: 1,
getNextPageParam: (lastPage) => {
if (lastPage.page < lastPage.total_pages) {
return lastPage.page + 1;
}
return undefined;
{
pageParamName: "page",
initialPageParam: 1,
getNextPageParam: (lastPage) => (lastPage.page < lastPage.total_pages ? lastPage.page + 1 : undefined),
enabled: Boolean(accessToken) && all_admin_roles.includes(userRole!),
},
enabled: Boolean(accessToken) && all_admin_roles.includes(userRole!),
});
);
};

View file

@ -1,8 +1,14 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QueryClient, QueryClientProvider, QueryCache } from "@tanstack/react-query";
import { handleError } from "@/components/networking";
import { deriveErrorMessage } from "@/lib/http/client";
const queryClient = new QueryClient();
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => handleError(deriveErrorMessage(error)),
}),
});
export default function ReactQueryProvider({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;

View file

@ -0,0 +1,24 @@
import createFetchClient from "openapi-fetch";
import createClient from "openapi-react-query";
import type { paths } from "./schema";
import { getProxyBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
// Placeholder origin so openapi-fetch always builds a parseable absolute URL (relative
// Request construction throws under Node/SSR). The onRequest middleware replaces it with
// the real, call-time base from getProxyBaseUrl on every request, so it never hits the network.
const PLACEHOLDER_ORIGIN = "http://litellm.local";
const fetchClient = createFetchClient<paths>({ baseUrl: PLACEHOLDER_ORIGIN });
fetchClient.use({
onRequest({ request }) {
const tail = new URL(request.url);
return new Request(getProxyBaseUrl() + tail.pathname + tail.search, request);
},
});
export const $api = createClient(fetchClient);
export const authHeader = (accessToken: string): Record<string, string> => ({
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
});