Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Util function for cleaner usage of TanStack useQuery #9395

Merged
merged 11 commits into from
Dec 13, 2024
Merged
10 changes: 9 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
QueryCache,
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { Suspense } from "react";

Expand All @@ -12,6 +16,7 @@ import AuthUserProvider from "@/Providers/AuthUserProvider";
import HistoryAPIProvider from "@/Providers/HistoryAPIProvider";
import Routers from "@/Routers";
import { FeatureFlagsProvider } from "@/Utils/featureFlags";
import { handleQueryError } from "@/Utils/request/errorHandler";

import { PubSubProvider } from "./Utils/pubsubContext";

Expand All @@ -23,6 +28,9 @@ const queryClient = new QueryClient({
staleTime: 5 * 60 * 1000, // 5 minutes
},
},
queryCache: new QueryCache({
onError: handleQueryError,
}),
});

const App = () => {
Expand Down
135 changes: 96 additions & 39 deletions src/Utils/request/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,36 +4,101 @@ CARE now uses TanStack Query (formerly React Query) as its data fetching solutio

## Using TanStack Query (Recommended for new code)

For new API integrations, we recommend using TanStack Query directly:
For new API integrations, we recommend using TanStack Query with `api.query`:

```tsx
import { useQuery } from "@tanstack/react-query";
import request from "@/Utils/request/request";
import FooRoutes from "@foo/routes";
import api from "@/Utils/request/api-request";

export default function FooDetails({ id }) {
const { data, isLoading, error } = useQuery({
queryKey: [FooRoutes.getFoo.path, id],
queryFn: async () => {
const response = await request(FooRoutes.getFoo, {
pathParams: { id }
});
return response.data;
}
export default function UserProfile() {
const { data, isLoading } = useQuery({
queryKey: [routes.users.current.path],
queryFn: api.query(routes.users.current)
});

if (isLoading) return <Loading />;
if (error) return <Error error={error} />;
return <div>{data?.name}</div>;
}

return (
<div>
<span>{data.id}</span>
<span>{data.name}</span>
</div>
);
// With path parameters
function PatientDetails({ id }: { id: string }) {
const { data } = useQuery({
queryKey: ['patient', id],
queryFn: api.query(routes.patient.get, {
pathParams: { id }
})
});

return <div>{data?.name}</div>;
}

// With query parameters
function SearchMedicines() {
const { data } = useQuery({
queryKey: ['medicines', 'paracetamol'],
queryFn: api.query(routes.medicine.search, {
queryParams: { search: 'paracetamol' }
})
});

return <MedicinesList medicines={data?.results} />;
}

// When you need response status/error handling
function FacilityDetails({ id }: { id: string }) {
const { data, isLoading } = useQuery({
queryKey: ["facility", id],
queryFn: api.query(routes.getFacility, {
pathParams: { id },
silent: true
})
});

if (isLoading) return <Loading />;
return <div>{data?.name}</div>;
}

### api.query

`api.query` is our wrapper around fetch that works seamlessly with TanStack Query. It:
- Handles response parsing (JSON, text, blobs)
- Constructs proper error objects
- Integrates with our global error handling

```typescript
interface QueryOptions {
pathParams?: Record<string, string>; // URL parameters
queryParams?: Record<string, string>; // Query string parameters
silent?: boolean; // Suppress error notifications
}

// Basic usage
useQuery({
queryKey: ["users"],
queryFn: api.query(routes.users.list)
});

// With parameters
useQuery({
queryKey: ["user", id],
queryFn: api.query(routes.users.get, {
pathParams: { id },
queryParams: { include: "details" },
silent: true // Optional: suppress error notifications
})
});
```

### Error Handling

All API errors are now handled globally. Common scenarios like:
- 404 responses -> Redirects to /not-found
- Session expiry -> Redirects to /session-expired
- Bad requests (400/406) -> Shows error notification
are automatically handled.

Use the `silent: true` option to suppress error notifications for specific queries.

## Migration Guide & Reference

### Understanding the Transition
Expand All @@ -60,12 +125,9 @@ function LegacyComponent({ id }) {
function ModernComponent({ id }) {
const { data, isLoading, error, refetch } = useQuery({
queryKey: [UserRoutes.getUser.path, id],
queryFn: async () => {
const response = await request(UserRoutes.getUser, {
pathParams: { id }
});
return response.data;
},
queryFn: api.query(UserRoutes.getUser, {
pathParams: { id }
}),
enabled: true,
refetchOnWindowFocus: false
});
Expand Down Expand Up @@ -100,7 +162,7 @@ useTanStackQueryInstead(route, { prefetch: shouldFetch })
// Modern
useQuery({
queryKey: [route.path],
queryFn: async () => (await request(route)).data,
queryFn: api.query(route),
enabled: shouldFetch
})
```
Expand All @@ -116,13 +178,10 @@ useTanStackQueryInstead(route, {
// Modern
useQuery({
queryKey: [route.path, id, filter],
queryFn: async () => {
const response = await request(route, {
pathParams: { id },
query: { filter }
});
return response.data;
}
queryFn: api.query(route, {
pathParams: { id },
queryParams: { filter }
})
})
```

Expand All @@ -135,12 +194,10 @@ if (res?.status === 403) handleForbidden();
// Modern
useQuery({
queryKey: [route.path],
queryFn: async () => {
const response = await request(route);
if (response.res.status === 403) handleForbidden();
return response.data;
},
onError: (error) => handleError(error)
queryFn: api.query(route, {
silent: true // Optional: suppress error notifications
})
// Error handling is now done globally
})
```

Expand Down
67 changes: 67 additions & 0 deletions src/Utils/request/api-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import careConfig from "@careConfig";

import { QueryOptions, Route } from "@/Utils/request/types";
import { makeHeaders, makeUrl } from "@/Utils/request/utils";

import { ResponseError } from "../response/responseError";
import { getResponseBody } from "./request";

async function queryRequest<TData, TBody>(
{ path, method, noAuth }: Route<TData, TBody>,
options?: QueryOptions<TBody> & { signal?: AbortSignal },
): Promise<TData> {
const url = `${careConfig.apiUrl}${makeUrl(path, options?.queryParams, options?.pathParams)}`;

const requestOptions: RequestInit = {
method,
headers: makeHeaders(noAuth ?? false),
signal: options?.signal,
};

if (options?.body) {
requestOptions.headers = {
...requestOptions.headers,
"Content-Type": "application/json",
};
requestOptions.body = JSON.stringify(options.body);
}

const res = await fetch(url, requestOptions);

if (!res.ok) {
const error = await res
.json()
.catch(() => ({ detail: "Something went wrong!" }));
throw new ResponseError({
name: error.name,
message: "Request Failed",
cause: {
...error,
code: error.code || (res.status === 404 ? "not_found" : undefined),
status: res.status,
silent: options?.silent,
detail: error.detail || "Something went wrong!",
},
});
}
rithviknishad marked this conversation as resolved.
Show resolved Hide resolved

return getResponseBody<TData>(res);
}

/**
* Creates a TanStack Query compatible request function
*/
function createQuery<TData, TBody>(
route: Route<TData, TBody>,
options?: QueryOptions<TBody>,
) {
return ({ signal }: { signal?: AbortSignal } = {}) => {
return queryRequest(route, { ...options, signal });
};
}

const api = {
query: createQuery,
} as const;

export default api;
70 changes: 70 additions & 0 deletions src/Utils/request/errorHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { navigate } from "raviger";

import * as Notifications from "@/Utils/Notifications";
import { ResponseError } from "@/Utils/response/responseError";

const notify = Notifications;

export function handleQueryError(error: Error) {
// Cast to ResponseError if it matches our expected structure
if (error instanceof ResponseError) {
const errorCause = error.cause;

// Ignore aborted requests
if (error?.name === "AbortError") return;

// Handle session expiry
if (isSessionExpired(errorCause)) {
handleSessionExpiry();
return;
}

// Handle bad requests
if (isBadRequest(errorCause)) {
if (!errorCause?.silent) notify.BadRequest({ errs: errorCause });
return;
}

// Handle not found
if (isNotFound(errorCause)) {
handleNotFound();
return;
}

// Handle other errors
if (!errorCause?.silent) {
notify.Error({ msg: errorCause?.detail || "Something went wrong!" });
}
} else {
// Handle non-ResponseError errors
notify.Error({ msg: error.message || "Something went wrong!" });
}
}

// Helper functions
function isSessionExpired(error: any) {
return (
error?.code === "token_not_valid" ||
error?.detail === "Authentication credentials were not provided."
);
}

function handleSessionExpiry() {
if (!location.pathname.startsWith("/session-expired")) {
navigate(`/session-expired?redirect=${window.location.href}`);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codebase verification

URL encoding required for session expiry redirect parameter

The codebase shows consistent usage of URL encoding in other parts where query parameters contain URLs or text (e.g., in DoctorVideoSlideover.tsx). The current implementation in errorHandler.ts directly uses window.location.href without encoding, which could cause issues with special characters in the URL.

  • src/Utils/request/errorHandler.ts: Add URL encoding using encodeURIComponent(window.location.href)
  • src/Utils/request/handleResponse.ts: Same change needed for consistency
🔗 Analysis chain

Verify URL encoding in session expiry redirect

The current implementation directly uses window.location.href in the redirect URL, which might need URL encoding.


Let me check if there are any existing implementations of URL encoding in the codebase.

🏁 Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Search for other redirect implementations to verify consistency
rg -A 2 "redirect.*window\.location"

Length of output: 417


Script:

#!/bin/bash
# Search for encodeURI or encodeURIComponent usage
rg -A 2 "encodeURI(Component)?"

# Search for any URL encoding related utility functions
ast-grep --pattern 'function $name($$$) { 
  $$$ 
  encodeURI($$$)
  $$$
}'

Length of output: 764


function isBadRequest(error: any) {
return error?.status === 400 || error?.status === 406;
}

function isNotFound(error: any) {
return error?.status === 404 || error?.code === "not_found";
}

function handleNotFound() {
if (!location.pathname.startsWith("/not-found")) {
navigate("/not-found");
}
}
2 changes: 1 addition & 1 deletion src/Utils/request/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export default async function request<TData, TBody>(
return result;
}

async function getResponseBody<TData>(res: Response): Promise<TData> {
export async function getResponseBody<TData>(res: Response): Promise<TData> {
if (!(res.headers.get("content-length") !== "0")) {
return null as TData;
}
Expand Down
8 changes: 8 additions & 0 deletions src/Utils/request/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ export interface RequestOptions<TData = unknown, TBody = unknown> {
silent?: boolean;
}

export interface QueryOptions<TBody = unknown> {
pathParams?: Record<string, string>;
queryParams?: Record<string, string>;
body?: TBody;
silent?: boolean;
signal?: AbortSignal;
}

export interface PaginatedResponse<TItem> {
count: number;
next: string | null;
Expand Down
27 changes: 27 additions & 0 deletions src/Utils/response/responseError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export class ResponseError extends Error {
name: string;
message: string;
cause: ErrorCause;

constructor({
name,
message,
cause,
}: {
name: string;
message: string;
cause: ErrorCause;
}) {
super(message);
this.name = name;
this.message = message;
this.cause = cause;
}
}
rithviknishad marked this conversation as resolved.
Show resolved Hide resolved

export interface ErrorCause {
code: string;
status: number;
silent: boolean | false;
detail: string;
}
Loading