API clients: Add generator (#104093)

* Add API client generator

* Extract config entry template

* Fix index file

* Fix message and file pattern

* Fix generate-rtk template

* Match generated-api

* Format

* Split helpers

* Cleanup

* Remove unused helpers

* Simplify group name handling

* Run generate-apis

* Prettier

* Format + lint

* improve lint/format

* Optional filterEndpoints

* Format

* Update readme

* More updates

* Move the helpers out

* Switch to TS

* Cleanup types

* Add support for Enterprise

* Add comments

* Refactor endpoint handling and update README

* Simplify checks

* Do not register reducers and middleware for enterprise

* More docs updates

* Remove redundant sections

* Format gitignored files

* Add limitations

* Simplify types

* Simplify path logic

* Do not format OSS paths for enterprise

* dedupe

* format

* Simplify instructions

* Update lockfile

* Add comments

* Remove custom types
pull/104428/head^2
Alex Khomenko 3 months ago committed by GitHub
parent 7ed17cacbf
commit 618ffd0275
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      package.json
  2. 136
      public/app/api/README.md
  3. 52
      public/app/api/generator/README.md
  4. 115
      public/app/api/generator/helpers.ts
  5. 165
      public/app/api/generator/plopfile.ts
  6. 14
      public/app/api/generator/templates/baseAPI.ts.hbs
  7. 9
      public/app/api/generator/templates/config-entry.hbs
  8. 3
      public/app/api/generator/templates/index.ts.hbs
  9. 27
      public/app/api/generator/types.ts
  10. 4
      public/app/core/reducers/root.ts
  11. 4
      public/app/store/configureStore.ts
  12. 1
      scripts/generate-rtk-apis.ts
  13. 760
      yarn.lock

@ -64,7 +64,8 @@
"plugin:build:commit": "nx run-many -t build:commit --projects='tag:scope:plugin'",
"plugin:build:dev": "nx run-many -t dev --projects='tag:scope:plugin' --maxParallel=100",
"process-specs": "node --experimental-strip-types scripts/process-specs.ts",
"generate-apis": "yarn process-specs && rtk-query-codegen-openapi ./scripts/generate-rtk-apis.ts"
"generate-apis": "yarn process-specs && rtk-query-codegen-openapi ./scripts/generate-rtk-apis.ts",
"generate:api-client": "NODE_OPTIONS='--experimental-strip-types' plop --plopfile public/app/api/generator/plopfile.ts"
},
"grafana": {
"whatsNewUrl": "https://grafana.com/docs/grafana/next/whatsnew/whats-new-in-v%[1]s-%[2]s/",
@ -216,6 +217,7 @@
"nx": "20.7.1",
"openapi-types": "^12.1.3",
"pdf-parse": "^1.1.1",
"plop": "^4.0.1",
"postcss": "8.5.1",
"postcss-loader": "8.1.1",
"postcss-reporter": "7.1.0",

@ -7,7 +7,7 @@ To show the steps to follow, we are going to work on adding an API client to cre
First, check if the `group` and the `version` are already present in [openapi_test.go](/pkg/tests/apis/openapi_test.go). If so, move on to the next step.
<br/> If you need to add a new block, you can check for the right `group` and `version` in the backend API call that you want to replicate in the frontend.
```jsx
```go
{
Group: "dashboard.grafana.app",
Version: "v0alpha1",
@ -20,136 +20,6 @@ Afterwards, you need to run the `TestIntegrationOpenAPIs` test. Note that it wil
> Note: You don’t need to follow these two steps if the `group` you’re working with is already in the `openapi_test.go` file.
<br/>
### 2. Create the API definition
In the [`/public/app/api/clients`](/public/app/api/clients) folder, create a new folder and `baseAPI.ts` file for your group. This file should have the following content:
```jsx
import { createApi } from '@reduxjs/toolkit/query/react';
import { createBaseQuery } from 'app/api/createBaseQuery';
import { getAPIBaseURL } from 'app/api/utils';
export const BASE_URL = getAPIBaseURL('dashboard.grafana.app', 'v0alpha1');
export const api = createApi({
reducerPath: 'dashboardAPI',
baseQuery: createBaseQuery({
baseURL: BASE_URL,
}),
endpoints: () => ({}),
});
```
This is the API definition for the specific group you're working with, where `getAPIBaseURL` should have the proper `group` and `version` as parameters. The `reducerPath` needs to be unique. The convention is to use `<group>API`: `dashboard` will be `dashboardAPI`, `iam` will be `iamAPI` and so on.
### 3. Add your new client to the generation script
Open [generate-rtk-apis.ts](/scripts/generate-rtk-apis.ts) and add the following information:
| Data | Descritpion |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| outputFile name | File that will be created after running the API Client Generation script. It is the key of the object. |
| apiFile | File with the group's API definition. |
| schemaFile | File with the schema that was automatically created in the second step. Although it is in openapi_snapshots, you should link the one saved in `data/openapi`. |
| filterEndpoints | The `operationId` of the particular route you want to work with. You can check the available operationIds in the specific group's spec file. As seen in the `migrate-to-cloud` one, it is an array |
|  tag | Must be set to `true`, to automatically attach tags to endpoints. This is needed for proper cache invalidation. See more info in the [official documentation](https://redux-toolkit.js.org/rtk-query/usage/automated-refetching#:~:text=RTK%20Query%20uses,an%20active%20subscription.).  |
<br/>
> More info in [Redux Toolkit](https://redux-toolkit.js.org/rtk-query/usage/code-generation#simple-usage)
In our example, the information added will be:
```jsx
'../public/app/api/clients/dashboard/endpoints.gen.ts': {
apiFile: '../public/app/api/clients/dashboard/baseAPI.ts',
schemaFile: '../data/openapi/dashboard.grafana.app-v0alpha1.json',
filterEndpoints: ['createDashboard', 'updateDashboard'],
tag: true,
},
```
### 4. Run the API client generation script
Then, we are ready to run the script to create the API client:
```jsx
yarn generate-apis
```
This will create an `endpoints.gen.ts` file in the path specified in the previous step.
### 5. Create the index file for your hooks
In the same `api` folder where the `endpoints.gen.ts` file has been saved, you have to create an index file from which you can import the types and hooks needed. By doing this, we selectively export hooks/types from `endpoints.gen.ts`.
In our case, the dashboard index will be like:
```jsx
import { generatedAPI } from './endpoints.gen';
export const dashboardAPI = generatedAPI;
export const { useCreateDashboardMutation, useUpdateDashboardMutation} = dashboardAPI;
// eslint-disable-next-line no-barrel-files/no-barrel-files
export { type Dashboard } from './endpoints.gen';
```
There are some use cases where the hook will not work out of the box, and that is a clue to see if it needs to be modified. The hooks can be tweaked by using `enhanceEndpoints`.
```jsx
export const dashboardsAPI = generatedApi.enhanceEndpoints({
endpoints: {
// Need to mutate the generated query to set the Content-Type header correctly
updateDashboard: (endpointDefinition) => {
const originalQuery = endpointDefinition.query;
if (originalQuery) {
endpointDefinition.query = (requestOptions) => ({
...originalQuery(requestOptions),
headers: {
'Content-Type': 'application/merge-patch+json',
},
});
}
},
},
});
```
### 6. Add reducers and middleware to the Redux store
Last but not least, you need to add the middleware and reducers to the store.
In Grafana, the reducers are added to [`root.ts`](/public/app/core/reducers/root.ts):
```jsx
import { dashboardAPI } from '<pathToYourAPI>';
const rootReducers = {
...,
[dashboardAPI.reducerPath]: dashboardAPI.reducer,
};
```
And the middleware is added to [`configureStore.ts`](/public/app/store/configureStore.ts):
```jsx
import { dashboardAPI } from '<pathToYourAPI>';
export function configureStore(initialState?: Partial<StoreState>) {
const store = reduxConfigureStore({
reducer: createRootReducer(),
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({ thunk: true, serializableCheck: false, immutableCheck: false }).concat(
...,
dashboardAPI.middleware
),
...,
});
```
You have available the official documentation in [RTK Query](https://redux-toolkit.js.org/tutorials/rtk-query#add-the-service-to-your-store)
### 2. Run the API generator script
After this step is done, it is time to use your hooks across Grafana.
Enjoy coding!
Run `yarn generate:api-client` and follow the prompts. See [API Client Generator](./generator/README.md) for details.

@ -0,0 +1,52 @@
# RTK Query API Client Generator
This generator automates the process of creating RTK Query API clients for Grafana's API groups. It replaces the manual steps outlined in the [main API documentation](../README.md).
## Usage
```bash
yarn generate:api-client
```
The CLI will prompt for:
1. **Enterprise or OSS API** - Whether this is an Enterprise or OSS API. This affects paths and build commands.
2. **API group name** - The basic name for the API (e.g., `dashboard`)
3. **API group** - The full API group name (defaults to `<group-name>.grafana.app`)
4. **API version** - The API version (e.g., `v0alpha1`)
5. **Reducer path** - The Redux reducer path (defaults to `<group-name>API`). This will also be used as the API's named export.
6. **Endpoints** - Optional comma-separated list of endpoints to include (e.g., `createDashboard,updateDashboard`). If not provided, all endpoints will be included.
## What It Does
The generator automates the following:
1. Creates the `baseAPI.ts` file for the API group
2. Updates the appropriate generate script to include the API client
- `scripts/generate-rtk-apis.ts` for OSS APIs
- `local/generate-enterprise-apis.ts` for Enterprise APIs
3. Creates the `index.ts` file with proper exports
4. For OSS APIs only: Registers Redux reducers and middleware in the store. For Enterprise this needs to be done manually
5. Formats all generated files using Prettier and ESLint
6. Automatically runs the appropriate command to generate endpoints from the OpenAPI schema
## Limitations
- The generator is optimized for Kubernetes-style APIs, as it requires Kubernetes resource details. For legacy APIs, manual adjustments may be needed.
- It expects processed OpenAPI specifications to exist in the `openapi_snapshots` directory
## Troubleshooting
### Missing OpenAPI Schema
If an error about a missing OpenAPI schema appears, check that:
1. The API group and version exist in the backend
2. The `TestIntegrationOpenAPIs` test has been run to generate the schema (step 1 in the [main API documentation](../README.md)).
3. The schema file exists at `data/openapi/<group>-<version>.json`
### Validation Errors
- API group must include `.grafana.app`
- Version must be in format `v0alpha1`, `v1beta2`, etc.
- Reducer path must end with `API`

@ -0,0 +1,115 @@
import { execSync } from 'child_process';
import path from 'path';
type PlopActionFunction = (
answers: Record<string, unknown>,
config?: Record<string, unknown>
) => string | Promise<string>;
// Helper to remove quotes from operation IDs
export const removeQuotes = (str: string | unknown) => {
if (typeof str !== 'string') {
return str;
}
return str.replace(/^['"](.*)['"]$/, '$1');
};
export const formatEndpoints = () => (endpointsInput: string | string[]) => {
if (Array.isArray(endpointsInput)) {
return endpointsInput.map((op) => `'${removeQuotes(op)}'`).join(', ');
}
// Handle string input (comma-separated)
if (typeof endpointsInput === 'string') {
const endpointsArray = endpointsInput
.split(',')
.map((id) => id.trim())
.filter(Boolean);
return endpointsArray.map((op) => `'${removeQuotes(op)}'`).join(', ');
}
return '';
};
// List of created or modified files
export const getFilesToFormat = (groupName: string, isEnterprise = false) => {
const apiClientBasePath = isEnterprise ? 'public/app/extensions/api/clients' : 'public/app/api/clients';
const generateScriptPath = isEnterprise ? 'local/generate-enterprise-apis.ts' : 'scripts/generate-rtk-apis.ts';
return [
`${apiClientBasePath}/${groupName}/baseAPI.ts`,
`${apiClientBasePath}/${groupName}/index.ts`,
generateScriptPath,
...(isEnterprise ? [] : [`public/app/core/reducers/root.ts`, `public/app/store/configureStore.ts`]),
];
};
export const runGenerateApis =
(basePath: string): PlopActionFunction =>
(answers, config) => {
try {
const isEnterprise = answers.isEnterprise || (config && config.isEnterprise);
let command;
if (isEnterprise) {
command = 'yarn process-specs && npx rtk-query-codegen-openapi ./local/generate-enterprise-apis.ts';
} else {
command = 'yarn generate-apis';
}
console.log(`⏳ Running ${command} to generate endpoints...`);
execSync(command, { stdio: 'inherit', cwd: basePath });
return '✅ API endpoints generated successfully!';
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('❌ Failed to generate API endpoints:', errorMessage);
return '❌ Failed to generate API endpoints. See error above.';
}
};
export const formatFiles =
(basePath: string): PlopActionFunction =>
(_, config) => {
if (!config || !Array.isArray(config.files)) {
console.error('Invalid config passed to formatFiles action');
return '❌ Formatting failed: Invalid configuration';
}
const filesToFormat = config.files.map((file: string) => path.join(basePath, file));
try {
const filesList = filesToFormat.map((file: string) => `"${file}"`).join(' ');
console.log('🧹 Running ESLint on generated/modified files...');
try {
execSync(`yarn eslint --fix ${filesList}`, { cwd: basePath });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(` Warning: ESLint encountered issues: ${errorMessage}`);
}
console.log('🧹 Running Prettier on generated/modified files...');
try {
// '--ignore-path' is necessary so the gitignored files ('local/' folder) can still be formatted
execSync(`yarn prettier --write ${filesList} --ignore-path=./.prettierignore`, { cwd: basePath });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(` Warning: Prettier encountered issues: ${errorMessage}`);
}
return '✅ Files linted and formatted successfully!';
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('⚠ Warning: Formatting operations failed:', errorMessage);
return '⚠ Warning: Formatting operations failed.';
}
};
export const validateGroup = (group: string) => {
return group && group.includes('.grafana.app') ? true : 'Group should be in format: name.grafana.app';
};
export const validateVersion = (version: string) => {
return version && /^v\d+[a-z]*\d+$/.test(version) ? true : 'Version should be in format: v0alpha1, v1beta2, etc.';
};

@ -0,0 +1,165 @@
import path from 'path';
import type { NodePlopAPI, PlopGeneratorConfig } from 'plop';
import {
formatEndpoints,
validateGroup,
validateVersion,
getFilesToFormat,
runGenerateApis,
formatFiles,
// The file extension is necessary to make the imports
// work with the '--experimental-strip-types' flag
// @ts-ignore
} from './helpers.ts';
// @ts-ignore
import { type ActionConfig, type PlopData, isPlopData } from './types.ts';
export default function plopGenerator(plop: NodePlopAPI) {
// Grafana root path
const basePath = path.resolve(import.meta.dirname, '../../../..');
// Register custom action types
plop.setActionType('runGenerateApis', runGenerateApis(basePath));
plop.setActionType('formatFiles', formatFiles(basePath));
// Used in templates to format endpoints
plop.setHelper('formatEndpoints', formatEndpoints());
const generateRtkApiActions = (data: PlopData) => {
const { reducerPath, groupName, isEnterprise } = data;
const apiClientBasePath = isEnterprise ? 'public/app/extensions/api/clients' : 'public/app/api/clients';
const generateScriptPath = isEnterprise ? 'local/generate-enterprise-apis.ts' : 'scripts/generate-rtk-apis.ts';
// Using app path, so the imports work on any file level
const clientImportPath = isEnterprise ? '../extensions/api/clients' : 'app/api/clients';
const apiPathPrefix = isEnterprise ? '../public/app/extensions/api/clients' : '../public/app/api/clients';
const templateData = {
...data,
apiPathPrefix,
};
// Base actions that are always added
const actions: ActionConfig[] = [
{
type: 'add',
path: path.join(basePath, `${apiClientBasePath}/${groupName}/baseAPI.ts`),
templateFile: './templates/baseAPI.ts.hbs',
},
{
type: 'modify',
path: path.join(basePath, generateScriptPath),
pattern: '// PLOP_INJECT_API_CLIENT - Used by the API client generator',
templateFile: './templates/config-entry.hbs',
data: templateData,
},
{
type: 'add',
path: path.join(basePath, `${apiClientBasePath}/${groupName}/index.ts`),
templateFile: './templates/index.ts.hbs',
},
];
// Only add redux reducer and middleware for OSS clients
if (!isEnterprise) {
actions.push(
{
type: 'modify',
path: path.join(basePath, 'public/app/core/reducers/root.ts'),
pattern: '// PLOP_INJECT_IMPORT',
template: `import { ${reducerPath} } from '${clientImportPath}/${groupName}';\n// PLOP_INJECT_IMPORT`,
},
{
type: 'modify',
path: path.join(basePath, 'public/app/core/reducers/root.ts'),
pattern: '// PLOP_INJECT_REDUCER',
template: `[${reducerPath}.reducerPath]: ${reducerPath}.reducer,\n // PLOP_INJECT_REDUCER`,
},
{
type: 'modify',
path: path.join(basePath, 'public/app/store/configureStore.ts'),
pattern: '// PLOP_INJECT_IMPORT',
template: `import { ${reducerPath} } from '${clientImportPath}/${groupName}';\n// PLOP_INJECT_IMPORT`,
},
{
type: 'modify',
path: path.join(basePath, 'public/app/store/configureStore.ts'),
pattern: '// PLOP_INJECT_MIDDLEWARE',
template: `${reducerPath}.middleware,\n // PLOP_INJECT_MIDDLEWARE`,
}
);
}
// Add formatting and generation actions
actions.push(
{
type: 'formatFiles',
files: getFilesToFormat(groupName, isEnterprise),
},
{
type: 'runGenerateApis',
isEnterprise,
}
);
return actions;
};
const generator: PlopGeneratorConfig = {
description: 'Generate RTK Query API client for a Grafana API group',
prompts: [
{
type: 'confirm',
name: 'isEnterprise',
message: 'Is this a Grafana Enterprise API?',
default: false,
},
{
type: 'input',
name: 'groupName',
message: 'API group name (e.g. dashboard):',
validate: (input: string) => (input?.trim() ? true : 'Group name is required'),
},
{
type: 'input',
name: 'group',
message: 'API group (e.g. dashboard.grafana.app):',
default: (answers: { groupName?: string }) => `${answers.groupName}.grafana.app`,
validate: validateGroup,
},
{
type: 'input',
name: 'version',
message: 'API version (e.g. v0alpha1):',
default: 'v0alpha1',
validate: validateVersion,
},
{
type: 'input',
name: 'reducerPath',
message: 'Reducer path (e.g. dashboardAPI):',
default: (answers: { groupName?: string }) => `${answers.groupName}API`,
validate: (input: string) =>
input?.endsWith('API') ? true : 'Reducer path should end with "API" (e.g. dashboardAPI)',
},
{
type: 'input',
name: 'endpoints',
message: 'Endpoints to include (comma-separated, optional):',
validate: () => true,
},
],
actions: function (data) {
if (!isPlopData(data)) {
throw new Error('Invalid data format received from prompts');
}
return generateRtkApiActions(data);
},
};
plop.setGenerator('rtk-api-client', generator);
}

@ -0,0 +1,14 @@
import { createApi } from '@reduxjs/toolkit/query/react';
import { createBaseQuery } from 'app/api/createBaseQuery';
import { getAPIBaseURL } from 'app/api/utils';
export const BASE_URL = getAPIBaseURL('{{group}}', '{{version}}');
export const api = createApi({
reducerPath: '{{reducerPath}}',
baseQuery: createBaseQuery({
baseURL: BASE_URL,
}),
endpoints: () => ({}),
});

@ -0,0 +1,9 @@
'{{apiPathPrefix}}/{{groupName}}/endpoints.gen.ts': {
apiFile: '{{apiPathPrefix}}/{{groupName}}/baseAPI.ts',
schemaFile: '../data/openapi/{{group}}-{{version}}.json',
{{#if endpoints}}
filterEndpoints: [{{{formatEndpoints endpoints}}}],
{{/if}}
tag: true,
},
// PLOP_INJECT_API_CLIENT - Used by the API client generator

@ -0,0 +1,3 @@
import { generatedAPI } from './endpoints.gen';
export const {{reducerPath}} = generatedAPI.enhanceEndpoints({});

@ -0,0 +1,27 @@
import type { AddActionConfig, ModifyActionConfig } from 'plop';
export interface FormatFilesActionConfig {
type: 'formatFiles';
files: string[];
}
export interface RunGenerateApisActionConfig {
type: 'runGenerateApis';
isEnterprise: boolean;
}
// Union type of all possible action configs
export type ActionConfig = AddActionConfig | ModifyActionConfig | FormatFilesActionConfig | RunGenerateApisActionConfig;
export interface PlopData {
groupName: string;
group: string;
version: string;
reducerPath: string;
endpoints: string;
isEnterprise: boolean;
}
export function isPlopData(data: unknown): data is PlopData {
return typeof data === 'object' && data !== null;
}

@ -35,6 +35,8 @@ import { provisioningAPI } from '../../api/clients/provisioning';
import { alertingApi } from '../../features/alerting/unified/api/alertingApi';
import { userPreferencesAPI } from '../../features/preferences/api';
import { cleanUpAction } from '../actions/cleanUp';
// Used by the API client generator
// PLOP_INJECT_IMPORT
const rootReducers = {
...sharedReducers,
@ -69,6 +71,8 @@ const rootReducers = {
[provisioningAPI.reducerPath]: provisioningAPI.reducer,
[folderAPI.reducerPath]: folderAPI.reducer,
[advisorAPI.reducerPath]: advisorAPI.reducer,
// PLOP_INJECT_REDUCER
// Used by the API client generator
};
const addedReducers = {};

@ -13,6 +13,8 @@ import { folderAPI } from '../api/clients/folder';
import { iamAPI } from '../api/clients/iam';
import { playlistAPI } from '../api/clients/playlist';
import { provisioningAPI } from '../api/clients/provisioning';
// Used by the API client generator
// PLOP_INJECT_IMPORT
import { buildInitialState } from '../core/reducers/navModel';
import { addReducer, createRootReducer } from '../core/reducers/root';
import { alertingApi } from '../features/alerting/unified/api/alertingApi';
@ -49,6 +51,8 @@ export function configureStore(initialState?: Partial<StoreState>) {
provisioningAPI.middleware,
folderAPI.middleware,
advisorAPI.middleware,
// PLOP_INJECT_MIDDLEWARE
// Used by the API client generator
...extraMiddleware
),
devTools: process.env.NODE_ENV !== 'production',

@ -72,6 +72,7 @@ const config: ConfigFile = {
filterEndpoints: ['listPlaylist', 'getPlaylist', 'createPlaylist', 'deletePlaylist', 'replacePlaylist'],
tag: true,
},
// PLOP_INJECT_API_CLIENT - Used by the API client generator
},
};

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save