Skip to content

Commit c8eb060

Browse files
committed
feat(nx-plugin): implement update-api executor for OpenAPI spec management
- Added a new executor to update the OpenAPI specification and regenerate the client code based on changes.
1 parent eb836a5 commit c8eb060

File tree

15 files changed

+794
-243
lines changed

15 files changed

+794
-243
lines changed

packages/nx-plugin/README.md

+25-4
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1-
# openapi-generator
1+
# @hey-api/nx-plugin
22

33
This library was generated with [Nx](https://nx.dev).
44

5-
## Running the generator
5+
## Generator
6+
7+
### openapi-client
8+
9+
This plugin provides a generator for generating OpenAPI clients using the `@hey-api/openapi-ts` library.
610

711
Run `nx g @hey-api/nx-plugin:openapi-client`
812

9-
## Options
13+
#### Options
1014

1115
- `name`: The name of the project. (required)
1216
- `scope`: The scope of the project. (required)
@@ -15,8 +19,25 @@ Run `nx g @hey-api/nx-plugin:openapi-client`
1519
- `client`: The type of client to generate. (optional) (default: `fetch`)
1620
- `tags`: The tags to add to the project. (optional) (default: `api,openapi`)
1721

18-
## Example
22+
#### Example
1923

2024
```bash
2125
nx g @hey-api/nx-plugin:openapi-client --name=my-api --client=fetch --scope=@my-app --directory=libs --spec=./spec.yaml --tags=api,openapi
2226
```
27+
28+
## Executors
29+
30+
### update-api
31+
32+
This executor updates the OpenAPI spec file and generates a new client.
33+
The options for the executor will be populated from the generator.
34+
35+
Run `nx run @my-org/my-generated-package:updateApi`
36+
37+
#### Options
38+
39+
- `spec`: The path to the OpenAPI spec file. (required)
40+
- `client`: The type of client to generate. (optional) (default: `fetch`)
41+
- `directory`: The directory to create the project in. (optional) (default: `libs`)
42+
- `name`: The name of the project. (required)
43+
- `scope`: The scope of the project. (required)

packages/nx-plugin/executors.json

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"executors": {
3+
"update-api": {
4+
"implementation": "./dist/executors/update-api",
5+
"schema": "./dist/executors/update-api/schema.json",
6+
"description": "update-api executor"
7+
}
8+
}
9+
}

packages/nx-plugin/package.json

+5-1
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,15 @@
4545
"default": "./dist/index.js"
4646
}
4747
},
48+
"executors": "./executors.json",
4849
"generators": "./generators.json",
4950
"dependencies": {
51+
"@apidevtools/swagger-parser": "10.1.1",
5052
"@hey-api/openapi-ts": "workspace:*",
51-
"@nx/devkit": "16.1.0",
53+
"@nx/devkit": "20.7.1",
5254
"@redocly/cli": "1.34.1",
55+
"nx": "20.7.1",
56+
"openapi-diff": "0.23.7",
5357
"tslib": "2.3.0"
5458
},
5559
"devDependencies": {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { ExecutorContext } from '@nx/devkit';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import executor from '.';
5+
import type { UpdateApiExecutorSchema } from './schema';
6+
7+
const options: UpdateApiExecutorSchema = {
8+
client: 'fetch',
9+
directory: 'libs',
10+
name: 'my-api',
11+
scope: '@my-org',
12+
spec: '',
13+
};
14+
const context: ExecutorContext = {
15+
cwd: process.cwd(),
16+
isVerbose: false,
17+
nxJsonConfiguration: {},
18+
projectGraph: {
19+
dependencies: {},
20+
nodes: {},
21+
},
22+
projectsConfigurations: {
23+
projects: {},
24+
version: 2,
25+
},
26+
root: '',
27+
};
28+
29+
describe('UpdateApi Executor', () => {
30+
it('can run', async () => {
31+
const output = await executor(options, context);
32+
expect(output.success).toBe(true);
33+
});
34+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { bundle } from '@apidevtools/swagger-parser';
2+
import type { PromiseExecutor } from '@nx/devkit';
3+
import { logger } from '@nx/devkit';
4+
import { execSync } from 'child_process';
5+
import { existsSync } from 'fs';
6+
import { cp, mkdir, readFile, rm, writeFile } from 'fs/promises';
7+
import OpenApiDiff from 'openapi-diff';
8+
import { join } from 'path';
9+
10+
import type { UpdateApiExecutorSchema } from './schema';
11+
12+
const tempFolder = join(process.cwd(), 'tmp');
13+
const tempApiFolder = join(tempFolder, 'api');
14+
15+
async function compareSpecs(existingSpecPath: string, newSpecPath: string) {
16+
logger.debug('Parsing existing spec...');
17+
const parsedExistingSpec = await bundle(existingSpecPath);
18+
logger.debug('Existing spec parsed.');
19+
const parsedNewSpec = await bundle(newSpecPath);
20+
logger.debug('New spec parsed.');
21+
22+
const existingSpecVersion = JSON.parse(
23+
JSON.stringify(parsedExistingSpec),
24+
).openapi;
25+
const newSpecVersion = JSON.parse(JSON.stringify(parsedNewSpec)).openapi;
26+
27+
logger.debug('Checking spec versions...');
28+
const existingVersionIs3 = existingSpecVersion.startsWith('3');
29+
const newSpecVersionIs3 = newSpecVersion.startsWith('3');
30+
const existingVersionIs2 = existingSpecVersion.startsWith('2');
31+
const newSpecVersionIs2 = newSpecVersion.startsWith('2');
32+
33+
if (!newSpecVersionIs3 && !newSpecVersionIs2) {
34+
logger.error('New spec is not a valid OpenAPI spec version of 2 or 3.');
35+
throw new Error('New spec is not a valid OpenAPI spec version of 2 or 3.');
36+
}
37+
38+
if (!existingVersionIs3 && !existingVersionIs2) {
39+
logger.error(
40+
'Existing spec is not a valid OpenAPI spec version of 2 or 3.',
41+
);
42+
throw new Error(
43+
'Existing spec is not a valid OpenAPI spec version of 2 or 3.',
44+
);
45+
}
46+
47+
logger.debug('Comparing specs...');
48+
// Compare specs
49+
const diff = await OpenApiDiff.diffSpecs({
50+
destinationSpec: {
51+
content: JSON.stringify(parsedNewSpec),
52+
format: newSpecVersionIs3 ? 'openapi3' : 'swagger2',
53+
location: newSpecPath,
54+
},
55+
sourceSpec: {
56+
content: JSON.stringify(parsedExistingSpec),
57+
format: existingVersionIs3 ? 'openapi3' : 'swagger2',
58+
location: existingSpecPath,
59+
},
60+
});
61+
const areSpecsEqual =
62+
diff.breakingDifferencesFound === false &&
63+
diff.nonBreakingDifferences.length === 0 &&
64+
// TODO: figure out if we should check unclassifiedDifferences
65+
diff.unclassifiedDifferences.length === 0;
66+
67+
logger.debug(`Are specs equal: ${areSpecsEqual}`);
68+
return areSpecsEqual;
69+
}
70+
71+
const runExecutor: PromiseExecutor<UpdateApiExecutorSchema> = async (
72+
options,
73+
) => {
74+
try {
75+
// Create temp folders if they don't exist
76+
if (!existsSync(tempFolder)) {
77+
await mkdir(tempFolder);
78+
}
79+
if (!existsSync(tempApiFolder)) {
80+
await mkdir(tempApiFolder);
81+
}
82+
83+
logger.debug('Temp folders created.');
84+
85+
// Determine file paths
86+
const projectRoot = join(options.directory, options.name);
87+
const apiDirectory = join(projectRoot, 'api');
88+
const existingSpecPath = join(apiDirectory, 'spec.yaml');
89+
const tempSpecPath = join(tempApiFolder, 'spec.yaml');
90+
const generatedTempDir = join(tempFolder, 'generated');
91+
92+
// Check if existing spec exists
93+
if (!existsSync(existingSpecPath)) {
94+
throw new Error(`No existing spec file found at ${existingSpecPath}.`);
95+
}
96+
97+
// Bundle and dereference the new spec file
98+
logger.debug(`Bundling new OpenAPI spec file using Redocly CLI...`);
99+
execSync(
100+
`npx redocly bundle ${options.spec} --output ${tempSpecPath} --ext yaml --dereferenced`,
101+
{
102+
stdio: 'inherit',
103+
},
104+
);
105+
106+
logger.debug('New spec bundled.');
107+
108+
// Read both files they can be yaml or json OpenAPI spec files
109+
logger.info('Reading existing and new spec files...');
110+
const newSpecString = await readFile(tempSpecPath, 'utf-8');
111+
if (!newSpecString) {
112+
logger.error('New spec file is empty.');
113+
throw new Error('New spec file is empty.');
114+
}
115+
const areSpecsEqual = await compareSpecs(existingSpecPath, tempSpecPath);
116+
// If specs are equal, we don't need to generate new client code and we can return
117+
if (areSpecsEqual) {
118+
logger.info('No changes detected in the API spec.');
119+
await cleanup();
120+
return { success: true };
121+
}
122+
// If specs are not equal, we need to generate new client code
123+
124+
// Generate new client code in temp directory
125+
logger.info('Changes detected in API spec. Generating new client code...');
126+
127+
// Create temp generated directory
128+
if (!existsSync(generatedTempDir)) {
129+
await mkdir(generatedTempDir);
130+
}
131+
132+
// Generate new client code
133+
try {
134+
execSync(
135+
`npx @hey-api/openapi-ts -i ${tempSpecPath} -o ${generatedTempDir} -c @hey-api/client-${options.client} -p @tanstack/react-query`,
136+
{
137+
stdio: 'inherit',
138+
},
139+
);
140+
} catch (error) {
141+
logger.error('Failed to generate new client code.');
142+
await cleanup();
143+
throw error;
144+
}
145+
146+
// If we got here, generation was successful. Update the files
147+
logger.info('Updating existing spec and client files...');
148+
149+
// Copy new spec to project
150+
await writeFile(existingSpecPath, newSpecString);
151+
152+
// Copy new generated code to project
153+
const projectGeneratedDir = join(projectRoot, 'src', 'generated');
154+
155+
// Remove old generated directory if it exists
156+
if (existsSync(projectGeneratedDir)) {
157+
await rm(projectGeneratedDir, { force: true, recursive: true });
158+
}
159+
160+
// Copy new generated directory
161+
await cp(generatedTempDir, projectGeneratedDir, { recursive: true });
162+
163+
logger.info('Successfully updated API client and spec files.');
164+
await cleanup();
165+
return { success: true };
166+
} catch (error) {
167+
logger.error(
168+
`Failed to update API: ${error instanceof Error ? error.message : String(error)}.`,
169+
);
170+
await cleanup();
171+
return { success: false };
172+
}
173+
};
174+
175+
async function cleanup() {
176+
if (existsSync(tempFolder)) {
177+
await rm(tempFolder, { force: true, recursive: true });
178+
}
179+
}
180+
181+
export default runExecutor;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export interface UpdateApiExecutorSchema {
2+
client: string;
3+
directory: string;
4+
name: string;
5+
scope: string;
6+
spec: string;
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"version": 2,
4+
"title": "UpdateApi executor",
5+
"description": "Update the OpenAPI spec file and client",
6+
"type": "object",
7+
"properties": {
8+
"name": {
9+
"type": "string",
10+
"description": "The name of the project",
11+
"x-prompt": "What is the name of the project? (e.g. my-api)",
12+
"$default": {
13+
"$source": "argv",
14+
"index": 0
15+
}
16+
},
17+
"spec": {
18+
"type": "string",
19+
"description": "The path to the OpenAPI spec file",
20+
"x-prompt": "What is the path to the OpenAPI spec file? (URI or local file path)"
21+
},
22+
"client": {
23+
"type": "string",
24+
"description": "The type of client to generate",
25+
"x-prompt": "What is the type of client to generate? (fetch, axios)",
26+
"default": "fetch",
27+
"enum": ["fetch", "axios"]
28+
},
29+
"scope": {
30+
"type": "string",
31+
"description": "The scope of the project",
32+
"x-prompt": "What is the scope of the project? (e.g. @my-org)"
33+
},
34+
"directory": {
35+
"type": "string",
36+
"description": "The directory of the project",
37+
"x-prompt": "What is the directory of the project? (e.g. libs)",
38+
"default": "libs"
39+
}
40+
},
41+
"required": ["spec", "scope", "name"]
42+
}

0 commit comments

Comments
 (0)