Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions static/app/utils/api/apiOptions.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,52 @@ import {expectTypeOf} from 'expect-type';

import {renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary';

import type {ApiMapping} from 'sentry/utils/api/apiContracts.generated';
import type {ApiResponse} from 'sentry/utils/api/apiFetch';
import {apiOptions, selectJsonWithHeaders} from 'sentry/utils/api/apiOptions';
import {parseQueryKey} from 'sentry/utils/api/apiQueryKey';

type Promisable<T> = T | Promise<T>;
type QueryFunctionResult<T> = Promisable<ApiResponse<T>>;
type TeamsResponse =
ApiMapping['/organizations/$organizationIdOrSlug/teams/']['GET']['response'];

describe('apiOptions', () => {
it('infers the response type from the backend contract', () => {
const options = apiOptions.contract('/organizations/$organizationIdOrSlug/teams/', {
staleTime: 0,
path: {organizationIdOrSlug: 'org-slug'},
});

expectTypeOf(options.queryFn).returns.toEqualTypeOf<
QueryFunctionResult<TeamsResponse>
>();
expect(options.queryKey).toEqual([
'/organizations/org-slug/teams/',
{},
{infinite: false},
]);
});

it('infers the infinite response type from the backend contract', () => {
const options = apiOptions.contractInfinite(
'/organizations/$organizationIdOrSlug/teams/',
{
staleTime: 0,
path: {organizationIdOrSlug: 'org-slug'},
}
);

expectTypeOf(options.queryFn).returns.toEqualTypeOf<
QueryFunctionResult<TeamsResponse>
>();
expect(options.queryKey).toEqual([
'/organizations/org-slug/teams/',
{},
{infinite: true},
]);
});

it('should encode path parameters correctly', () => {
const options = apiOptions.as<unknown>()(
'/organizations/$organizationIdOrSlug/releases/$version/',
Expand Down Expand Up @@ -271,6 +309,13 @@ describe('apiOptions', () => {
expectTypeOf(options.queryFn).returns.toEqualTypeOf<QueryFunctionResult<number>>();
});

it('should reject contract routes without a GET contract', () => {
// @ts-expect-error `/api-tokens/` declares no GET response type on the backend
apiOptions.contract('/api-tokens/', {staleTime: 0});
// @ts-expect-error `/api-tokens/` declares no GET response type on the backend
apiOptions.contractInfinite('/api-tokens/', {staleTime: 0});
});

it('should disallow unknown path if there are no path params', () => {
const options = apiOptions.as<number>()('/api-tokens/', {
staleTime: 0,
Expand Down
49 changes: 47 additions & 2 deletions static/app/utils/api/apiOptions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {SkipToken} from '@tanstack/react-query';
import {infiniteQueryOptions, queryOptions, skipToken} from '@tanstack/react-query';

import type {ApiMapping} from 'sentry/utils/api/apiContracts.generated';
import {apiFetch, apiFetchInfinite} from 'sentry/utils/api/apiFetch';
import type {ApiResponse} from 'sentry/utils/api/apiFetch';
import type {QueryKeyEndpointOptions} from 'sentry/utils/api/apiQueryKey';
Expand All @@ -12,6 +13,27 @@ import {parseLinkHeader} from 'sentry/utils/parseLinkHeader';

type KnownApiUrls = KnownGetsentryApiUrls | KnownSentryApiUrls;

/**
* Routes whose GET response type is known from the backend contract.
*/
export type ContractApiUrls = {
[TApiPath in keyof ApiMapping]: 'GET' extends keyof ApiMapping[TApiPath]
? TApiPath
: never;
}[keyof ApiMapping];

/**
* The response type the backend declares for `GET <path>`, from the generated
* `ApiMapping`. `unknown` for routes without a contract.
*/
export type ContractResponse<TApiPath extends string> = TApiPath extends keyof ApiMapping
? 'GET' extends keyof ApiMapping[TApiPath]
? ApiMapping[TApiPath]['GET'] extends {response: infer TResponse}
? TResponse
: unknown
: unknown
: unknown;

type Options = QueryKeyEndpointOptions & {staleTime: number | 'static'};

type PathParamOptions<TApiPath extends string> =
Expand Down Expand Up @@ -51,7 +73,8 @@ function _apiOptions<
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
TManualData = never,
TApiPath extends KnownApiUrls = KnownApiUrls,
// todo: infer the actual data type from the ApiMapping
// `apiOptions.as<T>()` passes T explicitly; `apiOptions.contract()` passes
// the response type the backend declares for the route.
TActualData = TManualData,
>(
path: TApiPath,
Expand Down Expand Up @@ -85,7 +108,6 @@ function _apiOptionsInfinite<
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
TManualData = never,
TApiPath extends KnownApiUrls = KnownApiUrls,
// todo: infer the actual data type from the ApiMapping
TActualData = TManualData,
>(
path: TApiPath,
Expand Down Expand Up @@ -152,6 +174,19 @@ function _apiOptionsInfinite<
* const items = data?.json ?? [];
* const pageLinks = data?.headers.Link;
* ```
*
* @example Inferring the response type from the backend contract
* ```ts
* const query = useQuery(
* apiOptions.contract('/organizations/$organizationIdOrSlug/teams/', {
* path: {organizationIdOrSlug: organization.slug},
* staleTime: 30_000,
* })
* );
* // query.data is the response type the backend declares for GET on this
* // route (see apiContracts.generated.ts). Only routes with a GET contract
* // are accepted; use `as<T>()` for the rest.
* ```
*/
export const apiOptions = {
as:
Expand All @@ -169,4 +204,14 @@ export const apiOptions = {
options: Options & PathParamOptions<TApiPath>
) =>
_apiOptionsInfinite<TManualData>(path, options as never),

contract: <TApiPath extends ContractApiUrls>(
path: TApiPath,
options: Options & PathParamOptions<TApiPath>
) => _apiOptions<ContractResponse<TApiPath>, TApiPath>(path, options as never),

contractInfinite: <TApiPath extends ContractApiUrls>(
path: TApiPath,
options: Options & PathParamOptions<TApiPath>
) => _apiOptionsInfinite<ContractResponse<TApiPath>, TApiPath>(path, options as never),
};
Loading