API client generation: Create new IAM api client and use in query library (#99888)

* create new generated iam api client and use in query library

* update betterer

* use new createBaseQuery method

* update CODEOWNERS

* fix unit tests

* use shared type

* update comment

* fix test
This commit is contained in:
Ashley Harrison 2025-01-31 17:12:55 +00:00 committed by GitHub
parent a21265a7ad
commit 9693212475
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 3936 additions and 71 deletions

View File

@ -5777,9 +5777,6 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"]
],
"public/app/features/query-library/api/query.ts:5381": [
[0, 0, 0, "\'@grafana/runtime/src/services/backendSrv\' import is restricted from being used by a pattern. Import from the public export instead.", "0"]
],
"public/app/features/query/components/QueryEditorRow.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],

1
.github/CODEOWNERS vendored
View File

@ -492,6 +492,7 @@ playwright.config.ts @grafana/plugins-platform-frontend
/public/app/features/explore/ @grafana/observability-traces-and-profiling
/public/app/features/expressions/ @grafana/observability-metrics
/public/app/features/folders/ @grafana/grafana-frontend-platform
/public/app/features/iam/ @grafana/grafana-frontend-platform
/public/app/features/inspector/ @grafana/dashboards-squad
/public/app/features/invites/ @grafana/grafana-frontend-platform
/public/app/features/library-panels/ @grafana/dashboards-squad

File diff suppressed because it is too large Load Diff

View File

@ -67,6 +67,9 @@ func TestIntegrationOpenAPIs(t *testing.T) {
}, {
Group: "peakq.grafana.app",
Version: "v0alpha1",
}, {
Group: "iam.grafana.app",
Version: "v0alpha1",
}}
for _, gv := range groups {
VerifyOpenAPISnapshots(t, dir, gv, h)

View File

@ -28,6 +28,7 @@ import usersReducers from 'app/features/users/state/reducers';
import templatingReducers from 'app/features/variables/state/keyedVariablesReducer';
import { alertingApi } from '../../features/alerting/unified/api/alertingApi';
import { iamApi } from '../../features/iam/api/api';
import { userPreferencesAPI } from '../../features/preferences/api';
import { queryLibraryApi } from '../../features/query-library/api/factory';
import { cleanUpAction } from '../actions/cleanUp';
@ -60,6 +61,7 @@ const rootReducers = {
[browseDashboardsAPI.reducerPath]: browseDashboardsAPI.reducer,
[cloudMigrationAPI.reducerPath]: cloudMigrationAPI.reducer,
[queryLibraryApi.reducerPath]: queryLibraryApi.reducer,
[iamApi.reducerPath]: iamApi.reducer,
[userPreferencesAPI.reducerPath]: userPreferencesAPI.reducer,
};

View File

@ -1,7 +1,7 @@
import { render, waitFor, screen } from '@testing-library/react';
import { AnnoKeyCreatedBy } from '../../apiserver/types';
import { ListQueryTemplateApiResponse } from '../../query-library/api/endpoints.gen';
import { CREATED_BY_KEY } from '../../query-library/api/types';
import { QueryTemplatesList } from './QueryTemplatesList';
import { QueryActionButtonProps } from './types';
@ -117,7 +117,7 @@ const testItems = [
name: 'TEST_QUERY',
creationTimestamp: '2025-01-01T11:11:11.00Z',
annotations: {
[CREATED_BY_KEY]: 'viewer:JohnDoe',
[AnnoKeyCreatedBy]: 'viewer:JohnDoe',
},
},
spec: {

View File

@ -10,7 +10,6 @@ import { useListQueryTemplateQuery } from 'app/features/query-library';
import { QueryTemplate } from 'app/features/query-library/types';
import { convertDataQueryResponseToQueryTemplates } from '../../query-library/api/mappers';
import { UserDataQueryResponse } from '../../query-library/api/types';
import { QueryLibraryProps } from './QueryLibrary';
import { queryLibraryTrackFilterDatasource } from './QueryLibraryAnalyticsEvents';
@ -33,9 +32,9 @@ export function QueryTemplatesList(props: QueryTemplatesListProps) {
const styles = useStyles2(getStyles);
const loadUsersResult = useLoadUsersWithError(data);
const userNames = loadUsersResult.value ? loadUsersResult.value.display.map((user) => user.displayName) : [];
const userNames = loadUsersResult.data ? loadUsersResult.data.display.map((user) => user.displayName) : [];
const loadQueryMetadataResult = useLoadQueryMetadataWithError(data, loadUsersResult.value);
const loadQueryMetadataResult = useLoadQueryMetadataWithError(data, loadUsersResult.data);
// Filtering right now is done just on the frontend until there is better backend support for this.
const filteredRows = useMemo(
@ -61,7 +60,7 @@ export function QueryTemplatesList(props: QueryTemplatesListProps) {
);
}
if (isLoading || loadUsersResult.loading || loadQueryMetadataResult.loading) {
if (isLoading || loadUsersResult.isLoading || loadQueryMetadataResult.loading) {
return <Spinner />;
}
@ -109,7 +108,7 @@ export function QueryTemplatesList(props: QueryTemplatesListProps) {
<Trans i18nKey="query-library.user-names">User name(s):</Trans>
</InlineLabel>
<MultiSelect
isLoading={loadUsersResult.loading}
isLoading={loadUsersResult.isLoading}
className={styles.multiSelect}
onChange={(items, actionMeta) => {
setUserFilters(items);
@ -166,7 +165,7 @@ function useLoadUsersWithError(data: QueryTemplate[] | undefined) {
*/
function useLoadQueryMetadataWithError(
queryTemplates: QueryTemplate[] | undefined,
userDataList: UserDataQueryResponse | undefined
userDataList: ReturnType<typeof useLoadUsers>['data']
) {
const result = useLoadQueryMetadata(queryTemplates, userDataList);

View File

@ -1,3 +1,4 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { compact, uniq } from 'lodash';
import { useAsync } from 'react-use';
import { AsyncState } from 'react-use/lib/useAsync';
@ -6,20 +7,20 @@ import { getDataSourceSrv } from '@grafana/runtime';
import { DataQuery, DataSourceRef } from '@grafana/schema';
import { createQueryText } from '../../../../core/utils/richHistory';
import { useGetDisplayListQuery } from '../../../iam';
import { getDatasourceSrv } from '../../../plugins/datasource_srv';
import { UserDataQueryResponse } from '../../../query-library/api/types';
import { getUserInfo } from '../../../query-library/api/user';
import { QueryTemplate } from '../../../query-library/types';
export function useLoadUsers(userUIDs: string[] | undefined) {
return useAsync(async () => {
if (!userUIDs) {
return undefined;
}
const userQtList = uniq(compact(userUIDs));
const usersParam = userQtList.map((userUid) => `key=${encodeURIComponent(userUid)}`).join('&');
return await getUserInfo(`?${usersParam}`);
}, [userUIDs]);
return useGetDisplayListQuery(
userUIDs
? {
name: `name?${usersParam}`,
}
: skipToken
);
}
// Explicitly type the result so TS knows to discriminate between the error result and good result by the error prop
@ -54,7 +55,7 @@ type MetadataValue =
*/
export function useLoadQueryMetadata(
queryTemplates: QueryTemplate[] | undefined,
userDataList: UserDataQueryResponse | undefined
userDataList: ReturnType<typeof useLoadUsers>['data']
): AsyncState<MetadataValue[]> {
return useAsync(async () => {
if (!(queryTemplates && userDataList)) {
@ -87,7 +88,7 @@ export function useLoadQueryMetadata(
user: {
uid: queryTemplate.user?.uid || '',
displayName: extendedUserData?.displayName || '',
avatarUrl: extendedUserData?.avatarUrl || '',
avatarUrl: extendedUserData?.avatarURL || '',
},
error: undefined,
};

View File

@ -27,6 +27,31 @@ interface MockQuery extends DataQuery {
expr: string;
}
jest.mock('../QueryLibrary/utils/dataFetching', () => {
return {
__esModule: true,
...jest.requireActual('../QueryLibrary/utils/dataFetching'),
useLoadUsers: () => {
return {
data: {
display: [
{
avatarUrl: '',
displayName: 'john doe',
identity: {
name: 'JohnDoe',
type: 'viewer',
},
},
],
},
isLoading: false,
error: null,
};
},
};
});
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
reportInteraction: (...args: object[]) => {

View File

@ -0,0 +1,14 @@
import { createApi } from '@reduxjs/toolkit/query/react';
import { createBaseQuery } from '../../../api/createBaseQuery';
import { getAPINamespace } from '../../../api/utils';
export const API_VERSION = 'iam.grafana.app/v0alpha1';
export const BASE_URL = `/apis/${API_VERSION}/namespaces/${getAPINamespace()}`;
export const iamApi = createApi({
baseQuery: createBaseQuery({ baseURL: BASE_URL }),
reducerPath: 'iamAPI',
endpoints: () => ({}),
});

View File

@ -0,0 +1,59 @@
import { iamApi as api } from './api';
export const addTagTypes = ['DisplayList'] as const;
const injectedRtkApi = api
.enhanceEndpoints({
addTagTypes,
})
.injectEndpoints({
endpoints: (build) => ({
getDisplayList: build.query<GetDisplayListApiResponse, GetDisplayListApiArg>({
query: (queryArg) => ({ url: `/display/${queryArg.name}` }),
providesTags: ['DisplayList'],
}),
}),
overrideExisting: false,
});
export { injectedRtkApi as generatedIamApi };
export type GetDisplayListApiResponse = /** status 200 OK */ DisplayList;
export type GetDisplayListApiArg = {
/** name of the DisplayList */
name: string;
};
export type IdentityRef = {
/** Name is the unique identifier for identity, guaranteed jo be a unique value for the type within a namespace. */
name: string;
/** Type of identity e.g. "user". For a full list see https://github.com/grafana/authlib/blob/2f8d13a83ca3e82da08b53726de1697ee5b5b4cc/claims/type.go#L15-L24 */
type: string;
};
export type Display = {
/** AvatarURL is the url where we can get the avatar for identity */
avatarURL?: string;
/** Display name for identity. */
displayName: string;
identity: IdentityRef;
/** InternalID is the legacy numreric id for identity, this is deprecated and should be phased out */
internalId?: number;
};
export type ListMeta = {
/** continue may be set if the user set a limit on the number of items returned, and indicates that the server has more data available. The value is opaque and may be used to issue another request to the endpoint that served this list to retrieve the next set of available objects. Continuing a consistent list may not be possible if the server configuration has changed or more than a few minutes have passed. The resourceVersion field returned when using this continue value will be identical to the value in the first response, unless you have received this token from an error message. */
continue?: string;
/** remainingItemCount is the number of subsequent items in the list which are not included in this list response. If the list request contained label or field selectors, then the number of remaining items is unknown and the field will be left unset and omitted during serialization. If the list is complete (either because it is not chunking or because this is the last chunk), then there are no more remaining items and this field will be left unset and omitted during serialization. Servers older than v1.15 do not set this field. The intended use of the remainingItemCount is *estimating* the size of a collection. Clients should not rely on the remainingItemCount to be set or to be exact. */
remainingItemCount?: number;
/** String that identifies the server's internal version of this object that can be used by clients to determine when objects have changed. Value must be treated as opaque by clients and passed unmodified back to the server. Populated by the system. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency */
resourceVersion?: string;
/** Deprecated: selfLink is a legacy read-only field that is no longer populated by the system. */
selfLink?: string;
};
export type DisplayList = {
/** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */
apiVersion?: string;
/** Matching items (the caller may need to remap from keys to results) */
display: Display[];
/** Input keys that were not useable */
invalidKeys?: string[];
/** Request keys used to lookup the display value */
keys: string[];
/** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */
kind?: string;
metadata?: ListMeta;
};

View File

@ -0,0 +1,38 @@
/**
* To generate iam k8s APIs, run:
* `yarn process-specs && npx rtk-query-codegen-openapi ./public/app/features/iam/api/scripts/generate-rtk-apis.ts` from the root of the repo
*/
import { ConfigFile } from '@rtk-query/codegen-openapi';
import { accessSync } from 'fs';
const schemaFile = '../../../../../../data/openapi/iam.grafana.app-v0alpha1.json';
try {
// Check we have the OpenAPI before generating query library RTK APIs,
// as this is currently a manual process
accessSync(schemaFile);
} catch (e) {
console.error('\nCould not find OpenAPI definition.\n');
console.error(
'Please run go test pkg/tests/apis/openapi_test.go to generate the OpenAPI definition, then try running this script again.\n'
);
throw e;
}
const config: ConfigFile = {
schemaFile,
apiFile: '',
tag: true,
outputFiles: {
'../endpoints.gen.ts': {
apiFile: '../api.ts',
apiImport: 'iamApi',
filterEndpoints: ['getDisplayList'],
exportName: 'generatedIamApi',
flattenArg: false,
},
},
};
export default config;

View File

@ -0,0 +1,3 @@
import { generatedIamApi } from './api/endpoints.gen';
export const { useGetDisplayListQuery } = generatedIamApi;

View File

@ -10,5 +10,6 @@ export const QUERY_LIBRARY_GET_LIMIT = 1000;
export const queryLibraryApi = createApi({
baseQuery: createBaseQuery({ baseURL: BASE_URL }),
reducerPath: 'queryLibraryAPI',
endpoints: () => ({}),
});

View File

@ -1,10 +1,10 @@
import { v4 as uuidv4 } from 'uuid';
import { AnnoKeyCreatedBy } from '../../apiserver/types';
import { AddQueryTemplateCommand, QueryTemplate } from '../types';
import { ListQueryTemplateApiResponse, QueryTemplate as QT } from './endpoints.gen';
import { API_VERSION, QueryTemplateKinds } from './query';
import { CREATED_BY_KEY } from './types';
export const convertDataQueryResponseToQueryTemplates = (result: ListQueryTemplateApiResponse): QueryTemplate[] => {
if (!result.items) {
@ -21,7 +21,7 @@ export const convertDataQueryResponseToQueryTemplates = (result: ListQueryTempla
})) ?? [],
createdAtTimestamp: new Date(spec.metadata?.creationTimestamp ?? '').getTime(),
user: {
uid: spec.metadata?.annotations?.[CREATED_BY_KEY] ?? '',
uid: spec.metadata?.annotations?.[AnnoKeyCreatedBy] ?? '',
},
};
});

View File

@ -1,26 +0,0 @@
// pkg/apis/iam/v0alpha1/types_display.go
export type UserDataQueryResponse = {
apiVersion: string;
kind: string;
metadata: {
selfLink: string;
resourceVersion: string;
continue: string;
remainingItemCount: number;
};
display: UserSpecResponse[];
keys: string[];
};
// pkg/apis/iam/v0alpha1/types_display.go
export type UserSpecResponse = {
avatarUrl: string;
displayName: string;
identity: {
name: string;
type: string;
};
internalId: number;
};
export const CREATED_BY_KEY = 'grafana.app/createdBy';

View File

@ -1,20 +0,0 @@
import { getBackendSrv } from '@grafana/runtime';
import { getAPINamespace } from '../../../api/utils';
import { UserDataQueryResponse } from './types';
/**
* @alpha
*/
export const API_VERSION = 'iam.grafana.app/v0alpha1';
/**
* @alpha
*/
const BASE_URL = `apis/${API_VERSION}/namespaces/${getAPINamespace()}/display`;
export async function getUserInfo(url?: string): Promise<UserDataQueryResponse> {
const userInfo = await getBackendSrv().get(`${BASE_URL}${url}`);
return userInfo;
}

View File

@ -11,6 +11,7 @@ import { StoreState } from 'app/types/store';
import { buildInitialState } from '../core/reducers/navModel';
import { addReducer, createRootReducer } from '../core/reducers/root';
import { alertingApi } from '../features/alerting/unified/api/alertingApi';
import { iamApi } from '../features/iam/api/api';
import { queryLibraryApi } from '../features/query-library/api/factory';
import { setStore } from './store';
@ -41,6 +42,7 @@ export function configureStore(initialState?: Partial<StoreState>) {
cloudMigrationAPI.middleware,
queryLibraryApi.middleware,
userPreferencesAPI.middleware,
iamApi.middleware,
...extraMiddleware
),
devTools: process.env.NODE_ENV !== 'production',