Chore: Move rtk-client generator to scripts (#105548)
* Chore: Move rtk-client generator to scripts * Fix path * Update codeowners
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user