Skip to content

Commit d4ad198

Browse files
authored
ref(tsc): refactor self-contained endpoints that need response headers to apiOptions (#112347)
as discussed in last week’s TSC, we need to start moving endpoints over to `apiOptions` so that we can re-use caches. the quickest win would be to implement `useApiQuery` with `apiOptions` internally, and to get there, we need to migrate endpoints that now need `getResponseHeader` over to `apiOptions` because those headers are exposed differently. this PR takes the first couple of endpoints that are (mostly) self-contained (no other usages of the url found) and moves them over. I’ve also exposed `selectJsonWithHeaders` because the default impl only selects `json` without `headers`. I plan to tackle the other occurrences with an endpoint-by-endpoint approach, as we need to identify all places where an endpoint is used (e.g. `invalidateQueries` or `getApiQueryData` or `setApiQueryData`) and migrate them together.
1 parent b456371 commit d4ad198

File tree

9 files changed

+244
-173
lines changed

9 files changed

+244
-173
lines changed

static/AGENTS.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,32 @@ const query = useQuery(
6060

6161
Existing code might use `useApiQuery` from `sentry/utils/queryClient` — prefer `apiOptions` for new code.
6262

63+
#### Accessing response headers (pagination, hit counts)
64+
65+
By default, `apiOptions` selects only the JSON body from the response. If you need response headers (e.g., `Link` for pagination or `X-Hits` / `X-Max-Hits` for total counts), override `select` with `selectJsonWithHeaders`:
66+
67+
```typescript
68+
import {useQuery} from '@tanstack/react-query';
69+
import {apiOptions, selectJsonWithHeaders} from 'sentry/utils/api/apiOptions';
70+
71+
const {data} = useQuery({
72+
...apiOptions.as<Item[]>()('/organizations/$organizationIdOrSlug/items/', {
73+
path: {organizationIdOrSlug: organization.slug},
74+
query: {cursor, per_page: 25},
75+
staleTime: 0,
76+
}),
77+
select: selectJsonWithHeaders,
78+
});
79+
80+
// data is ApiResponse<Item[]> — an object with `json` and `headers`
81+
const items = data?.json ?? [];
82+
const pageLinks = data?.headers.Link; // string | undefined
83+
const totalHits = data?.headers['X-Hits']; // number | undefined
84+
const maxHits = data?.headers['X-Max-Hits']; // number | undefined
85+
```
86+
87+
Note that `X-Hits` and `X-Max-Hits` are already parsed to `number | undefined` — no `parseInt` needed.
88+
6389
## General Frontend Rules
6490

6591
1. NO new Reflux stores

static/app/utils/api/apiOptions.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ type PathParamOptions<TApiPath extends string> =
2323
? {path?: never}
2424
: {path: Record<ExtractPathParams<TApiPath>, string | number> | SkipToken};
2525

26+
const selectJson = <TData>(data: ApiResponse<TData>) => data.json;
27+
28+
export const selectJsonWithHeaders = <TData>(
29+
data: ApiResponse<TData>
30+
): ApiResponse<TData> => data;
31+
2632
function _apiOptions<
2733
TManualData = never,
2834
TApiPath extends KnownApiUrls = KnownApiUrls,
@@ -46,7 +52,7 @@ function _apiOptions<
4652
queryFn: pathParams === skipToken ? skipToken : apiFetch<TActualData>,
4753
enabled: pathParams !== skipToken,
4854
staleTime,
49-
select: data => data.json,
55+
select: selectJson,
5056
});
5157
}
5258

@@ -86,6 +92,50 @@ function _apiOptionsInfinite<
8692
});
8793
}
8894

95+
/**
96+
* Type-safe factory for TanStack Query options that hit Sentry API endpoints.
97+
*
98+
* By default, `select` extracts the JSON body. To also access response headers
99+
* (e.g. `Link` for pagination), override with `selectJsonWithHeaders`.
100+
*
101+
* @example Basic usage
102+
* ```ts
103+
* const query = useQuery(
104+
* apiOptions.as<Project[]>()('/organizations/$organizationIdOrSlug/projects/', {
105+
* path: {organizationIdOrSlug: organization.slug},
106+
* staleTime: 30_000,
107+
* })
108+
* );
109+
* // query.data is Project[]
110+
* ```
111+
*
112+
* @example Conditional fetching
113+
* ```ts
114+
* const query = useQuery(
115+
* apiOptions.as<Project>()('/organizations/$organizationIdOrSlug/projects/$projectIdOrSlug/', {
116+
* path: projectSlug
117+
* ? {organizationIdOrSlug: organization.slug, projectIdOrSlug: projectSlug}
118+
* : skipToken,
119+
* staleTime: 30_000,
120+
* })
121+
* );
122+
* ```
123+
*
124+
* @example With response headers (pagination)
125+
* ```ts
126+
* const {data} = useQuery({
127+
* ...apiOptions.as<Item[]>()('/organizations/$organizationIdOrSlug/items/', {
128+
* path: {organizationIdOrSlug: organization.slug},
129+
* query: {cursor, per_page: 25},
130+
* staleTime: 0,
131+
* }),
132+
* select: selectJsonWithHeaders,
133+
* });
134+
* // data is ApiResponse<Item[]>
135+
* const items = data?.json ?? [];
136+
* const pageLinks = data?.headers.Link;
137+
* ```
138+
*/
89139
export const apiOptions = {
90140
as:
91141
<TManualData>() =>
Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import {getApiUrl} from 'sentry/utils/api/getApiUrl';
2-
import {useApiQuery} from 'sentry/utils/queryClient';
1+
import {useQuery} from '@tanstack/react-query';
2+
3+
import {apiOptions, selectJsonWithHeaders} from 'sentry/utils/api/apiOptions';
34
import {decodeList, decodeScalar} from 'sentry/utils/queryString';
45
import {hydratedSelectorData} from 'sentry/utils/replays/hydrateSelectorData';
56
import {useLocation} from 'sentry/utils/useLocation';
@@ -14,37 +15,37 @@ export function useDeadRageSelectors(params: DeadRageSelectorQueryParams) {
1415
const location = useLocation();
1516
const {query} = location;
1617

17-
const {isPending, isError, error, data, getResponseHeader} =
18-
useApiQuery<DeadRageSelectorListResponse>(
19-
[
20-
getApiUrl('/organizations/$organizationIdOrSlug/replay-selectors/', {
21-
path: {organizationIdOrSlug: organization.slug},
22-
}),
23-
{
24-
query: {
25-
query: '!count_dead_clicks:0',
26-
cursor: params.cursor,
27-
environment: decodeList(query.environment),
28-
project: query.project,
29-
statsPeriod: query.statsPeriod,
30-
start: decodeScalar(query.start),
31-
end: decodeScalar(query.end),
32-
per_page: params.per_page,
33-
sort: query[params.prefix + 'sort'] ?? params.sort,
34-
},
18+
const {isPending, isError, error, data} = useQuery({
19+
...apiOptions.as<DeadRageSelectorListResponse>()(
20+
'/organizations/$organizationIdOrSlug/replay-selectors/',
21+
{
22+
path: {organizationIdOrSlug: organization.slug},
23+
query: {
24+
query: '!count_dead_clicks:0',
25+
cursor: params.cursor,
26+
environment: decodeList(query.environment),
27+
project: query.project,
28+
statsPeriod: query.statsPeriod,
29+
start: decodeScalar(query.start),
30+
end: decodeScalar(query.end),
31+
per_page: params.per_page,
32+
sort: query[params.prefix + 'sort'] ?? params.sort,
3533
},
36-
],
37-
{staleTime: Infinity, enabled: params.enabled}
38-
);
34+
staleTime: Infinity,
35+
}
36+
),
37+
select: selectJsonWithHeaders,
38+
enabled: params.enabled,
39+
});
3940

4041
return {
4142
isLoading: isPending,
4243
isError,
4344
error,
4445
data: hydratedSelectorData(
45-
data ? data.data : [],
46+
data ? data.json.data : [],
4647
params.isWidgetData ? params.sort?.replace(/^-/, '') : null
4748
),
48-
pageLinks: getResponseHeader?.('Link') ?? undefined,
49+
pageLinks: data?.headers.Link,
4950
};
5051
}

static/app/views/alerts/list/rules/alertRulesList.tsx

Lines changed: 31 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {Fragment} from 'react';
22
import styled from '@emotion/styled';
3+
import {useQuery} from '@tanstack/react-query';
34
import type {Location} from 'history';
45

56
import {Alert} from '@sentry/scraps/alert';
@@ -22,12 +23,11 @@ import {IconArrow} from 'sentry/icons';
2223
import {t} from 'sentry/locale';
2324
import type {Project} from 'sentry/types/project';
2425
import {defined} from 'sentry/utils';
25-
import {getApiUrl} from 'sentry/utils/api/getApiUrl';
26+
import {apiOptions, selectJsonWithHeaders} from 'sentry/utils/api/apiOptions';
2627
import {uniq} from 'sentry/utils/array/uniq';
2728
import {VisuallyCompleteWithData} from 'sentry/utils/performanceForSentry';
2829
import {Projects} from 'sentry/utils/projects';
29-
import type {ApiQueryKey} from 'sentry/utils/queryClient';
30-
import {setApiQueryData, useApiQuery, useQueryClient} from 'sentry/utils/queryClient';
30+
import {useQueryClient} from 'sentry/utils/queryClient';
3131
import {useRouteAnalyticsEventNames} from 'sentry/utils/routeAnalytics/useRouteAnalyticsEventNames';
3232
import {useRouteAnalyticsParams} from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams';
3333
import {useApi} from 'sentry/utils/useApi';
@@ -45,7 +45,7 @@ import {RuleListRow} from './row';
4545
type SortField = 'date_added' | 'name' | ['incident_status', 'date_triggered'];
4646
const defaultSort: SortField = ['incident_status', 'date_triggered'];
4747

48-
function getAlertListQueryKey(orgSlug: string, query: Location['query']): ApiQueryKey {
48+
function getAlertListQueryParams(query: Location['query']) {
4949
const queryParams = {...query};
5050
queryParams.expand = ['latestIncident', 'lastTriggered'];
5151
queryParams.team = getTeamParams(queryParams.team!);
@@ -54,12 +54,18 @@ function getAlertListQueryKey(orgSlug: string, query: Location['query']): ApiQue
5454
queryParams.sort = defaultSort;
5555
}
5656

57-
return [
58-
getApiUrl('/organizations/$organizationIdOrSlug/combined-rules/', {
57+
return queryParams;
58+
}
59+
60+
function getAlertListApiOptions(orgSlug: string, query: Location['query']) {
61+
return apiOptions.as<Array<CombinedAlerts | null>>()(
62+
'/organizations/$organizationIdOrSlug/combined-rules/',
63+
{
5964
path: {organizationIdOrSlug: orgSlug},
60-
}),
61-
{query: queryParams},
62-
];
65+
query: getAlertListQueryParams(query),
66+
staleTime: 0,
67+
}
68+
);
6369
}
6470

6571
const DataConsentBanner = HookOrDefault({
@@ -82,18 +88,12 @@ export default function AlertRulesList() {
8288
});
8389

8490
// Fetch alert rules
85-
const {
86-
data: ruleListResponse = [],
87-
refetch,
88-
getResponseHeader,
89-
isPending,
90-
isError,
91-
} = useApiQuery<Array<CombinedAlerts | null>>(
92-
getAlertListQueryKey(organization.slug, location.query),
93-
{
94-
staleTime: 0,
95-
}
96-
);
91+
const alertListOptions = getAlertListApiOptions(organization.slug, location.query);
92+
const {data, refetch, isPending, isError} = useQuery({
93+
...alertListOptions,
94+
select: selectJsonWithHeaders,
95+
});
96+
const ruleListResponse = data?.json ?? [];
9797

9898
const handleChangeFilter = (activeFilters: string[]) => {
9999
const {cursor: _cursor, page: _page, ...currentQuery} = location.query;
@@ -166,11 +166,15 @@ export default function AlertRulesList() {
166166

167167
try {
168168
await api.requestPromise(deleteEndpoints[rule.type], {method: 'DELETE'});
169-
setApiQueryData<Array<CombinedAlerts | null>>(
170-
queryClient,
171-
getAlertListQueryKey(organization.slug, location.query),
172-
data => data?.filter(r => r?.id !== rule.id && r?.type !== rule.type)
173-
);
169+
queryClient.setQueryData(alertListOptions.queryKey, previous => {
170+
if (!previous) {
171+
return previous;
172+
}
173+
return {
174+
...previous,
175+
json: previous.json.filter(r => r?.id !== rule.id && r?.type !== rule.type),
176+
};
177+
});
174178
refetch();
175179
addSuccessMessage(t('Deleted rule'));
176180
} catch (_err) {
@@ -194,7 +198,7 @@ export default function AlertRulesList() {
194198
: rule.projects
195199
)
196200
);
197-
const ruleListPageLinks = getResponseHeader?.('Link');
201+
const ruleListPageLinks = data?.headers.Link;
198202

199203
const sort: {asc: boolean; field: SortField} = {
200204
asc: location.query.asc === '1',

static/app/views/alerts/rules/issue/details/issuesList.tsx

Lines changed: 20 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {Fragment} from 'react';
22
import {css} from '@emotion/react';
33
import styled from '@emotion/styled';
4+
import {useQuery} from '@tanstack/react-query';
45

56
import {Flex} from '@sentry/scraps/layout';
67
import {Link} from '@sentry/scraps/link';
@@ -15,10 +16,10 @@ import {t} from 'sentry/locale';
1516
import type {IssueAlertRule} from 'sentry/types/alerts';
1617
import type {Group} from 'sentry/types/group';
1718
import type {Project} from 'sentry/types/project';
18-
import {getApiUrl} from 'sentry/utils/api/getApiUrl';
19+
import {apiOptions, selectJsonWithHeaders} from 'sentry/utils/api/apiOptions';
1920
import {getMessage, getTitle} from 'sentry/utils/events';
2021
import type {FeedbackIssue} from 'sentry/utils/feedback/types';
21-
import {useApiQuery} from 'sentry/utils/queryClient';
22+
import {RequestError} from 'sentry/utils/requestError/requestError';
2223
import {useOrganization} from 'sentry/utils/useOrganization';
2324
import {makeFeedbackPathname} from 'sentry/views/feedback/pathnames';
2425

@@ -45,25 +46,15 @@ export function AlertRuleIssuesList({
4546
cursor,
4647
}: Props) {
4748
const organization = useOrganization();
48-
const {
49-
data: groupHistory,
50-
getResponseHeader,
51-
isPending,
52-
isError,
53-
error,
54-
} = useApiQuery<GroupHistory[]>(
55-
[
56-
getApiUrl(
57-
'/projects/$organizationIdOrSlug/$projectIdOrSlug/rules/$ruleId/group-history/',
58-
{
59-
path: {
60-
organizationIdOrSlug: organization.slug,
61-
projectIdOrSlug: project.slug,
62-
ruleId: rule.id,
63-
},
64-
}
65-
),
49+
const {data, isPending, error} = useQuery({
50+
...apiOptions.as<GroupHistory[]>()(
51+
'/projects/$organizationIdOrSlug/$projectIdOrSlug/rules/$ruleId/group-history/',
6652
{
53+
path: {
54+
organizationIdOrSlug: organization.slug,
55+
projectIdOrSlug: project.slug,
56+
ruleId: rule.id,
57+
},
6758
query: {
6859
per_page: 10,
6960
...(period && {statsPeriod: period}),
@@ -72,15 +63,17 @@ export function AlertRuleIssuesList({
7263
utc,
7364
cursor,
7465
},
75-
},
76-
],
77-
{staleTime: 0}
78-
);
66+
staleTime: 0,
67+
}
68+
),
69+
select: selectJsonWithHeaders,
70+
});
71+
const groupHistory = data?.json;
7972

80-
if (isError) {
73+
if (error instanceof RequestError) {
8174
return (
8275
<LoadingError
83-
message={(error?.responseJSON?.detail as string) ?? t('default message')}
76+
message={(error.responseJSON?.detail as string) ?? t('default message')}
8477
/>
8578
);
8679
}
@@ -140,7 +133,7 @@ export function AlertRuleIssuesList({
140133
})}
141134
</StyledPanelTable>
142135
<Flex justify="end" align="center" marginBottom="xl">
143-
<StyledPagination pageLinks={getResponseHeader?.('Link')} size="xs" />
136+
<StyledPagination pageLinks={data?.headers.Link} size="xs" />
144137
</Flex>
145138
</Fragment>
146139
);

0 commit comments

Comments
 (0)