Chore: Move rtk-client generator to scripts (#105548)

* Chore: Move rtk-client generator to scripts

* Fix path

* Update codeowners
This commit is contained in:
Alex Khomenko
2025-05-16 17:58:04 +03:00
committed by GitHub
parent 14d8c5796a
commit 48c5b55cdb
9 changed files with 4 additions and 3 deletions
+52
View File
@@ -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`
+115
View File
@@ -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.';
};
+165
View File
@@ -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({});
+27
View File
@@ -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;
}