@rvoh/psychic 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/src/bin/helpers/OpenApiSpecDiff.js +341 -0
- package/dist/cjs/src/bin/index.js +7 -0
- package/dist/cjs/src/cli/index.js +22 -1
- package/dist/cjs/src/controller/index.js +1 -1
- package/dist/cjs/src/devtools/helpers/launchDevServer.js +1 -1
- package/dist/cjs/src/error/psychic-app/init-missing-package-manager.js +1 -1
- package/dist/cjs/src/generate/helpers/generateResourceControllerSpecContent.js +5 -3
- package/dist/cjs/src/generate/helpers/syncOpenapiTypescript/installOpenapiTypescript.js +1 -1
- package/dist/cjs/src/openapi-renderer/SerializerOpenapiRenderer.js +1 -0
- package/dist/cjs/src/openapi-renderer/app.js +1 -1
- package/dist/esm/src/bin/helpers/OpenApiSpecDiff.js +341 -0
- package/dist/esm/src/bin/index.js +7 -0
- package/dist/esm/src/cli/index.js +22 -1
- package/dist/esm/src/controller/index.js +1 -1
- package/dist/esm/src/devtools/helpers/launchDevServer.js +1 -1
- package/dist/esm/src/error/psychic-app/init-missing-package-manager.js +1 -1
- package/dist/esm/src/generate/helpers/generateResourceControllerSpecContent.js +5 -3
- package/dist/esm/src/generate/helpers/syncOpenapiTypescript/installOpenapiTypescript.js +1 -1
- package/dist/esm/src/openapi-renderer/SerializerOpenapiRenderer.js +1 -0
- package/dist/esm/src/openapi-renderer/app.js +1 -1
- package/dist/types/src/bin/helpers/OpenApiSpecDiff.d.ts +138 -0
- package/dist/types/src/bin/index.d.ts +2 -0
- package/dist/types/src/controller/index.d.ts +1 -1
- package/dist/types/src/psychic-app/index.d.ts +28 -0
- package/package.json +16 -13
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { DreamCLI } from '@rvoh/dream/system';
|
|
2
|
+
import * as cp from 'node:child_process';
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import colorize from '../../cli/helpers/colorize.js';
|
|
6
|
+
/**
|
|
7
|
+
* Class-based OpenAPI specification diff tool
|
|
8
|
+
*
|
|
9
|
+
* Compares current OpenAPI specs against the head branch using oasdiff
|
|
10
|
+
*
|
|
11
|
+
* Example usages:
|
|
12
|
+
*
|
|
13
|
+
* Instance-based usage
|
|
14
|
+
* const diffTool = new OpenApiSpecDiff()
|
|
15
|
+
* diffTool.compare(openapiConfigs)
|
|
16
|
+
*
|
|
17
|
+
* Factory method usage
|
|
18
|
+
* const diffTool = OpenApiSpecDiff.create()
|
|
19
|
+
* diffTool.compare(openapiConfigs)
|
|
20
|
+
*
|
|
21
|
+
* Static method usage (backward compatibility)
|
|
22
|
+
* OpenApiSpecDiff.compare(openapiConfigs)
|
|
23
|
+
*/
|
|
24
|
+
export class OpenApiSpecDiff {
|
|
25
|
+
/**
|
|
26
|
+
* The configuration for the oasdiff command
|
|
27
|
+
*/
|
|
28
|
+
oasdiffConfig;
|
|
29
|
+
/**
|
|
30
|
+
* Compares a list of OpenAPI specifications between the current branch and the head branch.
|
|
31
|
+
*
|
|
32
|
+
* Uses `oasdiff` under the hood to detect breaking and non-breaking changes for each file,
|
|
33
|
+
* helping you review and validate API updates with confidence before merging.
|
|
34
|
+
*
|
|
35
|
+
* This tool only runs for configurations where `checkDiffs` is enabled.
|
|
36
|
+
*
|
|
37
|
+
* @param openapiConfigs - An array of tuples containing the OpenAPI file name and its configuration.
|
|
38
|
+
* @example
|
|
39
|
+
* ```ts
|
|
40
|
+
* const openapiConfigs: [string, PsychicOpenapiConfig][] = [
|
|
41
|
+
* ['openapi', { outputFilepath: 'openapi.json' }],
|
|
42
|
+
* ]
|
|
43
|
+
* OpenApiSpecDiff.compare(openapiConfigs)
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
compare(openapiConfigs) {
|
|
47
|
+
const results = [];
|
|
48
|
+
this.oasdiffConfig = this.getOasDiffConfig();
|
|
49
|
+
const comparing = colorize(`🔍 Comparing current OpenAPI Specs against ${this.oasdiffConfig.headBranch}...`, { color: 'cyanBright' });
|
|
50
|
+
DreamCLI.logger.logStartProgress(comparing, { logPrefixColor: 'cyanBright' });
|
|
51
|
+
DreamCLI.logger.logContinueProgress(`\n`, { logPrefixColor: 'cyanBright' });
|
|
52
|
+
for (const [openapiName, config] of openapiConfigs) {
|
|
53
|
+
const result = this.compareConfig(openapiName, config);
|
|
54
|
+
results.push(result);
|
|
55
|
+
}
|
|
56
|
+
this.processResults(results);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Checks if oasdiff is installed locally
|
|
60
|
+
*/
|
|
61
|
+
hasOasDiffInstalled() {
|
|
62
|
+
try {
|
|
63
|
+
cp.execSync('oasdiff --version', { stdio: 'ignore' });
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Fetch the head branch of a remote using git remote show origin
|
|
72
|
+
* default to main if not set
|
|
73
|
+
*/
|
|
74
|
+
getHeadBranch() {
|
|
75
|
+
let head = '';
|
|
76
|
+
const output = cp.execSync('git remote show origin', { encoding: 'utf-8' });
|
|
77
|
+
const lines = output.split('\n');
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
const trimmed = line.trim();
|
|
80
|
+
if (trimmed.startsWith('HEAD branch:')) {
|
|
81
|
+
head = trimmed.replace('HEAD branch:', '').trim() || 'main';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return head;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Validates that oasdiff is installed and builds the oasdiff config
|
|
88
|
+
*/
|
|
89
|
+
getOasDiffConfig() {
|
|
90
|
+
const headBranch = this.getHeadBranch();
|
|
91
|
+
if (this.hasOasDiffInstalled()) {
|
|
92
|
+
DreamCLI.logger.logContinueProgress('🎉 oasdiff package found\n');
|
|
93
|
+
return {
|
|
94
|
+
command: 'oasdiff',
|
|
95
|
+
baseArgs: [],
|
|
96
|
+
headBranch,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
throw new Error(`⚠️ oasdiff not found.
|
|
100
|
+
|
|
101
|
+
Install it via the instructions here:
|
|
102
|
+
https://github.com/tufin/oasdiff
|
|
103
|
+
`);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Runs oasdiff command and returns the output
|
|
107
|
+
*/
|
|
108
|
+
runOasDiffCommand(subcommand, mainPath, currentPath, flags) {
|
|
109
|
+
if (!this.oasdiffConfig) {
|
|
110
|
+
throw new Error('OasDiff config not initialized');
|
|
111
|
+
}
|
|
112
|
+
const args = [...this.oasdiffConfig.baseArgs, subcommand, mainPath, currentPath];
|
|
113
|
+
if (flags && flags.length > 0) {
|
|
114
|
+
args.push(...flags.map(flag => `--${flag}`));
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const output = cp.execFileSync(this.oasdiffConfig.command, args, {
|
|
118
|
+
shell: true,
|
|
119
|
+
encoding: 'utf8',
|
|
120
|
+
cwd: process.cwd(),
|
|
121
|
+
stdio: 'pipe',
|
|
122
|
+
});
|
|
123
|
+
return output.trim();
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
const errorOutput = error instanceof Error ? error.message : String(error);
|
|
127
|
+
return errorOutput;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Compares two OpenAPI files using oasdiff
|
|
132
|
+
*/
|
|
133
|
+
compareSpecs(mainFilePath, currentFilePath) {
|
|
134
|
+
if (!this.oasdiffConfig) {
|
|
135
|
+
throw new Error('OasDiff config not initialized');
|
|
136
|
+
}
|
|
137
|
+
const breakingChanges = this.runOasDiffCommand('breaking', mainFilePath, currentFilePath);
|
|
138
|
+
const changelogChanges = this.runOasDiffCommand('changelog', mainFilePath, currentFilePath);
|
|
139
|
+
const breaking = breakingChanges && !breakingChanges.includes('Command failed')
|
|
140
|
+
? breakingChanges.split('\n').filter(line => line.trim())
|
|
141
|
+
: [];
|
|
142
|
+
const changelog = changelogChanges && !changelogChanges.includes('Command failed')
|
|
143
|
+
? changelogChanges.split('\n').filter(line => line.trim())
|
|
144
|
+
: [];
|
|
145
|
+
const failedToCompare = breakingChanges.includes('Command failed')
|
|
146
|
+
? breakingChanges
|
|
147
|
+
: changelogChanges.includes('Command failed')
|
|
148
|
+
? changelogChanges
|
|
149
|
+
: '';
|
|
150
|
+
return {
|
|
151
|
+
breaking,
|
|
152
|
+
changelog,
|
|
153
|
+
error: failedToCompare,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Creates a temporary file path for the head branch content
|
|
158
|
+
*/
|
|
159
|
+
createTempFilePath(filePath) {
|
|
160
|
+
const tempFileName = `temp_main_${path.basename(filePath)}`;
|
|
161
|
+
return path.join(path.dirname(filePath), tempFileName);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Retrieves head branch content for a file
|
|
165
|
+
*/
|
|
166
|
+
getHeadBranchContent(filePath) {
|
|
167
|
+
if (!this.oasdiffConfig) {
|
|
168
|
+
throw new Error('OasDiff config not initialized');
|
|
169
|
+
}
|
|
170
|
+
const branchRef = process.env.CI === '1' ? `origin/${this.oasdiffConfig.headBranch}` : this.oasdiffConfig.headBranch;
|
|
171
|
+
return cp.execSync(`git show ${branchRef}:${filePath}`, {
|
|
172
|
+
encoding: 'utf8',
|
|
173
|
+
cwd: process.cwd(),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Compares a single OpenAPI file against head branch
|
|
178
|
+
*/
|
|
179
|
+
compareConfig(openapiName, config) {
|
|
180
|
+
const result = {
|
|
181
|
+
file: openapiName,
|
|
182
|
+
hasChanges: false,
|
|
183
|
+
breaking: [],
|
|
184
|
+
changelog: [],
|
|
185
|
+
};
|
|
186
|
+
const currentFilePath = config.outputFilepath;
|
|
187
|
+
if (!fs.existsSync(currentFilePath)) {
|
|
188
|
+
result.error = `File ${config.outputFilepath} does not exist in current branch`;
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
const tempMainFilePath = this.createTempFilePath(config.outputFilepath);
|
|
192
|
+
const mainContent = this.getHeadBranchContent(config.outputFilepath);
|
|
193
|
+
fs.mkdirSync(path.dirname(tempMainFilePath), { recursive: true });
|
|
194
|
+
fs.writeFileSync(tempMainFilePath, mainContent);
|
|
195
|
+
try {
|
|
196
|
+
const { breaking, changelog, error } = this.compareSpecs(tempMainFilePath, currentFilePath);
|
|
197
|
+
result.breaking = breaking;
|
|
198
|
+
result.changelog = changelog;
|
|
199
|
+
result.hasChanges = breaking.length > 0 || changelog.length > 0;
|
|
200
|
+
result.error = error ?? '';
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
result.error = `Could not retrieve ${config.outputFilepath} from ${this.oasdiffConfig?.headBranch} branch: ${String(error)}`;
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
if (fs.existsSync(tempMainFilePath)) {
|
|
207
|
+
fs.unlinkSync(tempMainFilePath);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Process and display the comparison results
|
|
214
|
+
*/
|
|
215
|
+
processResults(results) {
|
|
216
|
+
let hasAnyChanges = false;
|
|
217
|
+
let hasBreakingChanges = false;
|
|
218
|
+
for (const result of results) {
|
|
219
|
+
if (result.error) {
|
|
220
|
+
this.logError(result);
|
|
221
|
+
}
|
|
222
|
+
else if (result.hasChanges) {
|
|
223
|
+
this.logChanges(result);
|
|
224
|
+
hasAnyChanges = true;
|
|
225
|
+
if (result.breaking.length > 0) {
|
|
226
|
+
this.logBreakingChanges(result);
|
|
227
|
+
hasBreakingChanges = true;
|
|
228
|
+
}
|
|
229
|
+
if (result.changelog.length > 0) {
|
|
230
|
+
this.logChangelog(result);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
this.logNoChanges(result);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
this.logSummary(hasAnyChanges, hasBreakingChanges);
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Log error for a comparison result
|
|
241
|
+
*/
|
|
242
|
+
logError(result) {
|
|
243
|
+
const file = colorize(`❌ ${result.file}`, { color: 'whiteBright' });
|
|
244
|
+
const error = colorize(`${result.error}`, { color: 'redBright' });
|
|
245
|
+
DreamCLI.logger.logContinueProgress(`${file}: ${error}`, { logPrefixColor: 'redBright' });
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Log changes for a comparison result
|
|
249
|
+
*/
|
|
250
|
+
logChanges(result) {
|
|
251
|
+
const file = colorize(`${result.file}`, { color: 'whiteBright' });
|
|
252
|
+
const changes = colorize('HAS CHANGES', { color: 'yellowBright' });
|
|
253
|
+
DreamCLI.logger.logContinueProgress(`${file}: ${changes}`, { logPrefixColor: 'yellowBright' });
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Log breaking changes for a comparison result
|
|
257
|
+
*/
|
|
258
|
+
logBreakingChanges(result) {
|
|
259
|
+
DreamCLI.logger.logContinueProgress(` ${colorize(`🚨 BREAKING CHANGES:`, { color: 'redBright' })}`, {
|
|
260
|
+
logPrefixColor: 'redBright',
|
|
261
|
+
});
|
|
262
|
+
result.breaking.forEach(change => {
|
|
263
|
+
DreamCLI.logger.logContinueProgress(` ${colorize(`• ${change}`, { color: 'redBright' })}`, {
|
|
264
|
+
logPrefixColor: 'redBright',
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Log changelog for a comparison result
|
|
270
|
+
*/
|
|
271
|
+
logChangelog(result) {
|
|
272
|
+
DreamCLI.logger.logContinueProgress(` ${colorize(`📋 CHANGELOG:`, { color: 'blueBright' })}`, {
|
|
273
|
+
logPrefixColor: 'blueBright',
|
|
274
|
+
});
|
|
275
|
+
const changelogLines = result.changelog;
|
|
276
|
+
changelogLines.forEach(line => {
|
|
277
|
+
DreamCLI.logger.logContinueProgress(` ${colorize(line, { color: 'whiteBright' })}`, {
|
|
278
|
+
logPrefixBgColor: 'bgWhite',
|
|
279
|
+
logPrefixColor: 'white',
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Log no changes for a comparison result
|
|
285
|
+
*/
|
|
286
|
+
logNoChanges(result) {
|
|
287
|
+
const file = colorize(`${result.file}`, { color: 'whiteBright' });
|
|
288
|
+
const changes = colorize('No changes', { color: 'greenBright' });
|
|
289
|
+
DreamCLI.logger.logContinueProgress(`${file}: ${changes}`, { logPrefixColor: 'greenBright' });
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Log final summary and handle exit conditions
|
|
293
|
+
*/
|
|
294
|
+
logSummary(hasAnyChanges, hasBreakingChanges) {
|
|
295
|
+
DreamCLI.logger.logContinueProgress(`\n${colorize(`${'='.repeat(60)}`, { color: 'gray' })}`, {
|
|
296
|
+
logPrefixColor: 'gray',
|
|
297
|
+
});
|
|
298
|
+
if (hasBreakingChanges) {
|
|
299
|
+
DreamCLI.logger.logContinueProgress(`${colorize(`🚨 CRITICAL:`, { color: 'redBright' })} ${colorize(`Breaking changes detected in current branch compared to ${this.oasdiffConfig?.headBranch}! Review before merging.`, { color: 'whiteBright' })}`, { logPrefixColor: 'redBright' });
|
|
300
|
+
DreamCLI.logger.logContinueProgress(`${colorize(`${'='.repeat(60)}`, { color: 'gray' })}`, {
|
|
301
|
+
logPrefixColor: 'gray',
|
|
302
|
+
});
|
|
303
|
+
DreamCLI.logger.logContinueProgress('\n'.repeat(5), {
|
|
304
|
+
logPrefixColor: 'gray',
|
|
305
|
+
});
|
|
306
|
+
throw new BreakingChangesDetectedInOpenApiSpecError(this.oasdiffConfig);
|
|
307
|
+
}
|
|
308
|
+
else if (hasAnyChanges) {
|
|
309
|
+
const summary = colorize(`📊 Summary: Some OpenAPI files have non-breaking changes in current branch compared to ${this.oasdiffConfig?.headBranch}`, { color: 'yellow' });
|
|
310
|
+
DreamCLI.logger.logContinueProgress(summary, { logPrefixColor: 'yellow' });
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
const summary = colorize(`📊 Summary: All OpenAPI files in current branch are identical to ${this.oasdiffConfig?.headBranch} branch`, { color: 'green' });
|
|
314
|
+
DreamCLI.logger.logContinueProgress(summary, { logPrefixColor: 'green' });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Static factory method for convenience
|
|
319
|
+
*/
|
|
320
|
+
static create() {
|
|
321
|
+
return new OpenApiSpecDiff();
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Static method to maintain compatibility with functional approach
|
|
325
|
+
*/
|
|
326
|
+
static compare(openapiConfigs) {
|
|
327
|
+
const instance = OpenApiSpecDiff.create();
|
|
328
|
+
instance.compare(openapiConfigs);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
export class BreakingChangesDetectedInOpenApiSpecError extends Error {
|
|
332
|
+
oasdiffConfig;
|
|
333
|
+
constructor(oasdiffConfig) {
|
|
334
|
+
super();
|
|
335
|
+
this.oasdiffConfig = oasdiffConfig;
|
|
336
|
+
this.name = 'BreakingChangesDetectedInOpenApiSpecError';
|
|
337
|
+
}
|
|
338
|
+
get message() {
|
|
339
|
+
return `Breaking changes detected in current branch compared to ${this.oasdiffConfig.headBranch}! Review before merging.`;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
@@ -9,7 +9,9 @@ import OpenapiAppRenderer from '../openapi-renderer/app.js';
|
|
|
9
9
|
import PsychicApp from '../psychic-app/index.js';
|
|
10
10
|
import enumsFileStr from './helpers/enumsFileStr.js';
|
|
11
11
|
import generateRouteTypes from './helpers/generateRouteTypes.js';
|
|
12
|
+
import { OpenApiSpecDiff } from './helpers/OpenApiSpecDiff.js';
|
|
12
13
|
import printRoutes from './helpers/printRoutes.js';
|
|
14
|
+
export { BreakingChangesDetectedInOpenApiSpecError } from './helpers/OpenApiSpecDiff.js';
|
|
13
15
|
export default class PsychicBin {
|
|
14
16
|
static async generateController(controllerName, actions) {
|
|
15
17
|
await generateController({
|
|
@@ -59,6 +61,11 @@ export default class PsychicBin {
|
|
|
59
61
|
await new ASTPsychicTypesBuilder().build();
|
|
60
62
|
});
|
|
61
63
|
}
|
|
64
|
+
static openapiDiff() {
|
|
65
|
+
const psychicApp = PsychicApp.getOrFail();
|
|
66
|
+
const openapiConfigsWithCheckDiffs = Object.entries(psychicApp.openapi).filter(([, config]) => config.checkDiffs);
|
|
67
|
+
OpenApiSpecDiff.compare(openapiConfigsWithCheckDiffs);
|
|
68
|
+
}
|
|
62
69
|
static async syncOpenapiTypescriptFiles() {
|
|
63
70
|
DreamCLI.logger.logStartProgress(`syncing openapi types...`);
|
|
64
71
|
// https://rvohealth.atlassian.net/browse/PDTC-8359
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DreamCLI } from '@rvoh/dream/system';
|
|
2
|
-
import PsychicBin from '../bin/index.js';
|
|
2
|
+
import PsychicBin, { BreakingChangesDetectedInOpenApiSpecError } from '../bin/index.js';
|
|
3
3
|
import generateController from '../generate/controller.js';
|
|
4
4
|
import generateSyncEnumsInitializer from '../generate/initializer/syncEnums.js';
|
|
5
5
|
import generateSyncOpenapiTypescriptInitializer from '../generate/initializer/syncOpenapiTypescript.js';
|
|
@@ -196,6 +196,27 @@ export default class PsychicCLI {
|
|
|
196
196
|
await PsychicBin.syncOpenapiJson();
|
|
197
197
|
process.exit();
|
|
198
198
|
});
|
|
199
|
+
program
|
|
200
|
+
.command('diff:openapi')
|
|
201
|
+
.description('compares the current branch open api spec file(s) with the main/master/head branch open api spec file(s)')
|
|
202
|
+
.option('-f', '--fail-on-breaking', 'fail on spec changes that are breaking')
|
|
203
|
+
.action(async (options) => {
|
|
204
|
+
await initializePsychicApp();
|
|
205
|
+
try {
|
|
206
|
+
PsychicBin.openapiDiff();
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
if (error instanceof BreakingChangesDetectedInOpenApiSpecError) {
|
|
210
|
+
if (options.failOnBreaking) {
|
|
211
|
+
console.error(error.message);
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
});
|
|
199
220
|
}
|
|
200
221
|
/**
|
|
201
222
|
* @internal
|
|
@@ -131,7 +131,7 @@ export default class PsychicController {
|
|
|
131
131
|
/**
|
|
132
132
|
* @internal
|
|
133
133
|
*
|
|
134
|
-
* Used for displaying routes when running `
|
|
134
|
+
* Used for displaying routes when running `pnpm psy routes`
|
|
135
135
|
* cli command
|
|
136
136
|
*/
|
|
137
137
|
static get disaplayName() {
|
|
@@ -6,7 +6,7 @@ import UnexpectedUndefined from '../../error/UnexpectedUndefined.js';
|
|
|
6
6
|
import PsychicApp from '../../psychic-app/index.js';
|
|
7
7
|
const devServerProcesses = {};
|
|
8
8
|
const debugEnabled = debuglog('psychic').enabled;
|
|
9
|
-
export async function launchDevServer(key, { port = 3000, cmd = '
|
|
9
|
+
export async function launchDevServer(key, { port = 3000, cmd = 'pnpm client', timeout = 5000 } = {}) {
|
|
10
10
|
if (devServerProcesses[key])
|
|
11
11
|
return;
|
|
12
12
|
if (debugEnabled)
|
|
@@ -87,8 +87,9 @@ function parseAttribute(attribute) {
|
|
|
87
87
|
const [rawAttributeName, rawAttributeType, , enumValues] = attribute.split(':');
|
|
88
88
|
if (!rawAttributeName || !rawAttributeType)
|
|
89
89
|
return null;
|
|
90
|
+
const sanitizedAttrType = camelize(rawAttributeType)?.toLowerCase();
|
|
90
91
|
// Handle belongs_to relationships
|
|
91
|
-
if (
|
|
92
|
+
if (sanitizedAttrType === 'belongsto') {
|
|
92
93
|
// For belongs_to relationships, convert "Ticketing/Ticket" to "ticket"
|
|
93
94
|
const attributeName = camelize(rawAttributeName.split('/').pop());
|
|
94
95
|
return { attributeName, attributeType: 'belongs_to', isArray: false, enumValues };
|
|
@@ -105,8 +106,9 @@ function parseAttribute(attribute) {
|
|
|
105
106
|
return { attributeName, attributeType, isArray, enumValues };
|
|
106
107
|
}
|
|
107
108
|
function processAttributeByType({ attributeType, attributeName, isArray, enumValues, dotNotationVariable, fullyQualifiedModelName, attributeData, }) {
|
|
108
|
-
|
|
109
|
-
|
|
109
|
+
const sanitizedAttributeType = camelize(attributeType).toLowerCase();
|
|
110
|
+
switch (sanitizedAttributeType) {
|
|
111
|
+
case 'belongsto':
|
|
110
112
|
// belongs_to relationships are NOT included in comparable attributes or create/update data
|
|
111
113
|
// They are only tracked for original values in update contexts
|
|
112
114
|
break;
|
|
@@ -8,7 +8,7 @@ export default async function installOpenapiTypescript() {
|
|
|
8
8
|
try {
|
|
9
9
|
await DreamCLI.spawn(cmd);
|
|
10
10
|
}
|
|
11
|
-
catch
|
|
11
|
+
catch {
|
|
12
12
|
console.log(`Failed to install openapi-typescript as a dev dependency. Please make sure the following command succeeds:
|
|
13
13
|
|
|
14
14
|
"${cmd}"
|
|
@@ -298,6 +298,7 @@ function associationOpenapi(attribute, DataTypeForOpenapi, alreadyExtractedDesce
|
|
|
298
298
|
if (associatedClass === undefined) {
|
|
299
299
|
let serializerCheck;
|
|
300
300
|
try {
|
|
301
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
301
302
|
;
|
|
302
303
|
DataTypeForOpenapi?.prototype?.serializers;
|
|
303
304
|
}
|
|
@@ -57,7 +57,7 @@ export default class OpenapiAppRenderer {
|
|
|
57
57
|
openapi: '3.1.0',
|
|
58
58
|
info: {
|
|
59
59
|
version: openapiConfig?.info?.version || 'unknown version',
|
|
60
|
-
title: openapiConfig?.info?.title ||
|
|
60
|
+
title: openapiConfig?.info?.title || `${psychicApp.appName} | ${openapiName}`,
|
|
61
61
|
description: openapiConfig?.info?.description || 'The autogenerated openapi spec for your app',
|
|
62
62
|
},
|
|
63
63
|
paths: {},
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { DreamCLI } from '@rvoh/dream/system';
|
|
2
|
+
import * as cp from 'node:child_process';
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import colorize from '../../cli/helpers/colorize.js';
|
|
6
|
+
/**
|
|
7
|
+
* Class-based OpenAPI specification diff tool
|
|
8
|
+
*
|
|
9
|
+
* Compares current OpenAPI specs against the head branch using oasdiff
|
|
10
|
+
*
|
|
11
|
+
* Example usages:
|
|
12
|
+
*
|
|
13
|
+
* Instance-based usage
|
|
14
|
+
* const diffTool = new OpenApiSpecDiff()
|
|
15
|
+
* diffTool.compare(openapiConfigs)
|
|
16
|
+
*
|
|
17
|
+
* Factory method usage
|
|
18
|
+
* const diffTool = OpenApiSpecDiff.create()
|
|
19
|
+
* diffTool.compare(openapiConfigs)
|
|
20
|
+
*
|
|
21
|
+
* Static method usage (backward compatibility)
|
|
22
|
+
* OpenApiSpecDiff.compare(openapiConfigs)
|
|
23
|
+
*/
|
|
24
|
+
export class OpenApiSpecDiff {
|
|
25
|
+
/**
|
|
26
|
+
* The configuration for the oasdiff command
|
|
27
|
+
*/
|
|
28
|
+
oasdiffConfig;
|
|
29
|
+
/**
|
|
30
|
+
* Compares a list of OpenAPI specifications between the current branch and the head branch.
|
|
31
|
+
*
|
|
32
|
+
* Uses `oasdiff` under the hood to detect breaking and non-breaking changes for each file,
|
|
33
|
+
* helping you review and validate API updates with confidence before merging.
|
|
34
|
+
*
|
|
35
|
+
* This tool only runs for configurations where `checkDiffs` is enabled.
|
|
36
|
+
*
|
|
37
|
+
* @param openapiConfigs - An array of tuples containing the OpenAPI file name and its configuration.
|
|
38
|
+
* @example
|
|
39
|
+
* ```ts
|
|
40
|
+
* const openapiConfigs: [string, PsychicOpenapiConfig][] = [
|
|
41
|
+
* ['openapi', { outputFilepath: 'openapi.json' }],
|
|
42
|
+
* ]
|
|
43
|
+
* OpenApiSpecDiff.compare(openapiConfigs)
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
compare(openapiConfigs) {
|
|
47
|
+
const results = [];
|
|
48
|
+
this.oasdiffConfig = this.getOasDiffConfig();
|
|
49
|
+
const comparing = colorize(`🔍 Comparing current OpenAPI Specs against ${this.oasdiffConfig.headBranch}...`, { color: 'cyanBright' });
|
|
50
|
+
DreamCLI.logger.logStartProgress(comparing, { logPrefixColor: 'cyanBright' });
|
|
51
|
+
DreamCLI.logger.logContinueProgress(`\n`, { logPrefixColor: 'cyanBright' });
|
|
52
|
+
for (const [openapiName, config] of openapiConfigs) {
|
|
53
|
+
const result = this.compareConfig(openapiName, config);
|
|
54
|
+
results.push(result);
|
|
55
|
+
}
|
|
56
|
+
this.processResults(results);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Checks if oasdiff is installed locally
|
|
60
|
+
*/
|
|
61
|
+
hasOasDiffInstalled() {
|
|
62
|
+
try {
|
|
63
|
+
cp.execSync('oasdiff --version', { stdio: 'ignore' });
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Fetch the head branch of a remote using git remote show origin
|
|
72
|
+
* default to main if not set
|
|
73
|
+
*/
|
|
74
|
+
getHeadBranch() {
|
|
75
|
+
let head = '';
|
|
76
|
+
const output = cp.execSync('git remote show origin', { encoding: 'utf-8' });
|
|
77
|
+
const lines = output.split('\n');
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
const trimmed = line.trim();
|
|
80
|
+
if (trimmed.startsWith('HEAD branch:')) {
|
|
81
|
+
head = trimmed.replace('HEAD branch:', '').trim() || 'main';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return head;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Validates that oasdiff is installed and builds the oasdiff config
|
|
88
|
+
*/
|
|
89
|
+
getOasDiffConfig() {
|
|
90
|
+
const headBranch = this.getHeadBranch();
|
|
91
|
+
if (this.hasOasDiffInstalled()) {
|
|
92
|
+
DreamCLI.logger.logContinueProgress('🎉 oasdiff package found\n');
|
|
93
|
+
return {
|
|
94
|
+
command: 'oasdiff',
|
|
95
|
+
baseArgs: [],
|
|
96
|
+
headBranch,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
throw new Error(`⚠️ oasdiff not found.
|
|
100
|
+
|
|
101
|
+
Install it via the instructions here:
|
|
102
|
+
https://github.com/tufin/oasdiff
|
|
103
|
+
`);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Runs oasdiff command and returns the output
|
|
107
|
+
*/
|
|
108
|
+
runOasDiffCommand(subcommand, mainPath, currentPath, flags) {
|
|
109
|
+
if (!this.oasdiffConfig) {
|
|
110
|
+
throw new Error('OasDiff config not initialized');
|
|
111
|
+
}
|
|
112
|
+
const args = [...this.oasdiffConfig.baseArgs, subcommand, mainPath, currentPath];
|
|
113
|
+
if (flags && flags.length > 0) {
|
|
114
|
+
args.push(...flags.map(flag => `--${flag}`));
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const output = cp.execFileSync(this.oasdiffConfig.command, args, {
|
|
118
|
+
shell: true,
|
|
119
|
+
encoding: 'utf8',
|
|
120
|
+
cwd: process.cwd(),
|
|
121
|
+
stdio: 'pipe',
|
|
122
|
+
});
|
|
123
|
+
return output.trim();
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
const errorOutput = error instanceof Error ? error.message : String(error);
|
|
127
|
+
return errorOutput;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Compares two OpenAPI files using oasdiff
|
|
132
|
+
*/
|
|
133
|
+
compareSpecs(mainFilePath, currentFilePath) {
|
|
134
|
+
if (!this.oasdiffConfig) {
|
|
135
|
+
throw new Error('OasDiff config not initialized');
|
|
136
|
+
}
|
|
137
|
+
const breakingChanges = this.runOasDiffCommand('breaking', mainFilePath, currentFilePath);
|
|
138
|
+
const changelogChanges = this.runOasDiffCommand('changelog', mainFilePath, currentFilePath);
|
|
139
|
+
const breaking = breakingChanges && !breakingChanges.includes('Command failed')
|
|
140
|
+
? breakingChanges.split('\n').filter(line => line.trim())
|
|
141
|
+
: [];
|
|
142
|
+
const changelog = changelogChanges && !changelogChanges.includes('Command failed')
|
|
143
|
+
? changelogChanges.split('\n').filter(line => line.trim())
|
|
144
|
+
: [];
|
|
145
|
+
const failedToCompare = breakingChanges.includes('Command failed')
|
|
146
|
+
? breakingChanges
|
|
147
|
+
: changelogChanges.includes('Command failed')
|
|
148
|
+
? changelogChanges
|
|
149
|
+
: '';
|
|
150
|
+
return {
|
|
151
|
+
breaking,
|
|
152
|
+
changelog,
|
|
153
|
+
error: failedToCompare,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Creates a temporary file path for the head branch content
|
|
158
|
+
*/
|
|
159
|
+
createTempFilePath(filePath) {
|
|
160
|
+
const tempFileName = `temp_main_${path.basename(filePath)}`;
|
|
161
|
+
return path.join(path.dirname(filePath), tempFileName);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Retrieves head branch content for a file
|
|
165
|
+
*/
|
|
166
|
+
getHeadBranchContent(filePath) {
|
|
167
|
+
if (!this.oasdiffConfig) {
|
|
168
|
+
throw new Error('OasDiff config not initialized');
|
|
169
|
+
}
|
|
170
|
+
const branchRef = process.env.CI === '1' ? `origin/${this.oasdiffConfig.headBranch}` : this.oasdiffConfig.headBranch;
|
|
171
|
+
return cp.execSync(`git show ${branchRef}:${filePath}`, {
|
|
172
|
+
encoding: 'utf8',
|
|
173
|
+
cwd: process.cwd(),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Compares a single OpenAPI file against head branch
|
|
178
|
+
*/
|
|
179
|
+
compareConfig(openapiName, config) {
|
|
180
|
+
const result = {
|
|
181
|
+
file: openapiName,
|
|
182
|
+
hasChanges: false,
|
|
183
|
+
breaking: [],
|
|
184
|
+
changelog: [],
|
|
185
|
+
};
|
|
186
|
+
const currentFilePath = config.outputFilepath;
|
|
187
|
+
if (!fs.existsSync(currentFilePath)) {
|
|
188
|
+
result.error = `File ${config.outputFilepath} does not exist in current branch`;
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
const tempMainFilePath = this.createTempFilePath(config.outputFilepath);
|
|
192
|
+
const mainContent = this.getHeadBranchContent(config.outputFilepath);
|
|
193
|
+
fs.mkdirSync(path.dirname(tempMainFilePath), { recursive: true });
|
|
194
|
+
fs.writeFileSync(tempMainFilePath, mainContent);
|
|
195
|
+
try {
|
|
196
|
+
const { breaking, changelog, error } = this.compareSpecs(tempMainFilePath, currentFilePath);
|
|
197
|
+
result.breaking = breaking;
|
|
198
|
+
result.changelog = changelog;
|
|
199
|
+
result.hasChanges = breaking.length > 0 || changelog.length > 0;
|
|
200
|
+
result.error = error ?? '';
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
result.error = `Could not retrieve ${config.outputFilepath} from ${this.oasdiffConfig?.headBranch} branch: ${String(error)}`;
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
if (fs.existsSync(tempMainFilePath)) {
|
|
207
|
+
fs.unlinkSync(tempMainFilePath);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Process and display the comparison results
|
|
214
|
+
*/
|
|
215
|
+
processResults(results) {
|
|
216
|
+
let hasAnyChanges = false;
|
|
217
|
+
let hasBreakingChanges = false;
|
|
218
|
+
for (const result of results) {
|
|
219
|
+
if (result.error) {
|
|
220
|
+
this.logError(result);
|
|
221
|
+
}
|
|
222
|
+
else if (result.hasChanges) {
|
|
223
|
+
this.logChanges(result);
|
|
224
|
+
hasAnyChanges = true;
|
|
225
|
+
if (result.breaking.length > 0) {
|
|
226
|
+
this.logBreakingChanges(result);
|
|
227
|
+
hasBreakingChanges = true;
|
|
228
|
+
}
|
|
229
|
+
if (result.changelog.length > 0) {
|
|
230
|
+
this.logChangelog(result);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
this.logNoChanges(result);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
this.logSummary(hasAnyChanges, hasBreakingChanges);
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Log error for a comparison result
|
|
241
|
+
*/
|
|
242
|
+
logError(result) {
|
|
243
|
+
const file = colorize(`❌ ${result.file}`, { color: 'whiteBright' });
|
|
244
|
+
const error = colorize(`${result.error}`, { color: 'redBright' });
|
|
245
|
+
DreamCLI.logger.logContinueProgress(`${file}: ${error}`, { logPrefixColor: 'redBright' });
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Log changes for a comparison result
|
|
249
|
+
*/
|
|
250
|
+
logChanges(result) {
|
|
251
|
+
const file = colorize(`${result.file}`, { color: 'whiteBright' });
|
|
252
|
+
const changes = colorize('HAS CHANGES', { color: 'yellowBright' });
|
|
253
|
+
DreamCLI.logger.logContinueProgress(`${file}: ${changes}`, { logPrefixColor: 'yellowBright' });
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Log breaking changes for a comparison result
|
|
257
|
+
*/
|
|
258
|
+
logBreakingChanges(result) {
|
|
259
|
+
DreamCLI.logger.logContinueProgress(` ${colorize(`🚨 BREAKING CHANGES:`, { color: 'redBright' })}`, {
|
|
260
|
+
logPrefixColor: 'redBright',
|
|
261
|
+
});
|
|
262
|
+
result.breaking.forEach(change => {
|
|
263
|
+
DreamCLI.logger.logContinueProgress(` ${colorize(`• ${change}`, { color: 'redBright' })}`, {
|
|
264
|
+
logPrefixColor: 'redBright',
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Log changelog for a comparison result
|
|
270
|
+
*/
|
|
271
|
+
logChangelog(result) {
|
|
272
|
+
DreamCLI.logger.logContinueProgress(` ${colorize(`📋 CHANGELOG:`, { color: 'blueBright' })}`, {
|
|
273
|
+
logPrefixColor: 'blueBright',
|
|
274
|
+
});
|
|
275
|
+
const changelogLines = result.changelog;
|
|
276
|
+
changelogLines.forEach(line => {
|
|
277
|
+
DreamCLI.logger.logContinueProgress(` ${colorize(line, { color: 'whiteBright' })}`, {
|
|
278
|
+
logPrefixBgColor: 'bgWhite',
|
|
279
|
+
logPrefixColor: 'white',
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Log no changes for a comparison result
|
|
285
|
+
*/
|
|
286
|
+
logNoChanges(result) {
|
|
287
|
+
const file = colorize(`${result.file}`, { color: 'whiteBright' });
|
|
288
|
+
const changes = colorize('No changes', { color: 'greenBright' });
|
|
289
|
+
DreamCLI.logger.logContinueProgress(`${file}: ${changes}`, { logPrefixColor: 'greenBright' });
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Log final summary and handle exit conditions
|
|
293
|
+
*/
|
|
294
|
+
logSummary(hasAnyChanges, hasBreakingChanges) {
|
|
295
|
+
DreamCLI.logger.logContinueProgress(`\n${colorize(`${'='.repeat(60)}`, { color: 'gray' })}`, {
|
|
296
|
+
logPrefixColor: 'gray',
|
|
297
|
+
});
|
|
298
|
+
if (hasBreakingChanges) {
|
|
299
|
+
DreamCLI.logger.logContinueProgress(`${colorize(`🚨 CRITICAL:`, { color: 'redBright' })} ${colorize(`Breaking changes detected in current branch compared to ${this.oasdiffConfig?.headBranch}! Review before merging.`, { color: 'whiteBright' })}`, { logPrefixColor: 'redBright' });
|
|
300
|
+
DreamCLI.logger.logContinueProgress(`${colorize(`${'='.repeat(60)}`, { color: 'gray' })}`, {
|
|
301
|
+
logPrefixColor: 'gray',
|
|
302
|
+
});
|
|
303
|
+
DreamCLI.logger.logContinueProgress('\n'.repeat(5), {
|
|
304
|
+
logPrefixColor: 'gray',
|
|
305
|
+
});
|
|
306
|
+
throw new BreakingChangesDetectedInOpenApiSpecError(this.oasdiffConfig);
|
|
307
|
+
}
|
|
308
|
+
else if (hasAnyChanges) {
|
|
309
|
+
const summary = colorize(`📊 Summary: Some OpenAPI files have non-breaking changes in current branch compared to ${this.oasdiffConfig?.headBranch}`, { color: 'yellow' });
|
|
310
|
+
DreamCLI.logger.logContinueProgress(summary, { logPrefixColor: 'yellow' });
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
const summary = colorize(`📊 Summary: All OpenAPI files in current branch are identical to ${this.oasdiffConfig?.headBranch} branch`, { color: 'green' });
|
|
314
|
+
DreamCLI.logger.logContinueProgress(summary, { logPrefixColor: 'green' });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Static factory method for convenience
|
|
319
|
+
*/
|
|
320
|
+
static create() {
|
|
321
|
+
return new OpenApiSpecDiff();
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Static method to maintain compatibility with functional approach
|
|
325
|
+
*/
|
|
326
|
+
static compare(openapiConfigs) {
|
|
327
|
+
const instance = OpenApiSpecDiff.create();
|
|
328
|
+
instance.compare(openapiConfigs);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
export class BreakingChangesDetectedInOpenApiSpecError extends Error {
|
|
332
|
+
oasdiffConfig;
|
|
333
|
+
constructor(oasdiffConfig) {
|
|
334
|
+
super();
|
|
335
|
+
this.oasdiffConfig = oasdiffConfig;
|
|
336
|
+
this.name = 'BreakingChangesDetectedInOpenApiSpecError';
|
|
337
|
+
}
|
|
338
|
+
get message() {
|
|
339
|
+
return `Breaking changes detected in current branch compared to ${this.oasdiffConfig.headBranch}! Review before merging.`;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
@@ -9,7 +9,9 @@ import OpenapiAppRenderer from '../openapi-renderer/app.js';
|
|
|
9
9
|
import PsychicApp from '../psychic-app/index.js';
|
|
10
10
|
import enumsFileStr from './helpers/enumsFileStr.js';
|
|
11
11
|
import generateRouteTypes from './helpers/generateRouteTypes.js';
|
|
12
|
+
import { OpenApiSpecDiff } from './helpers/OpenApiSpecDiff.js';
|
|
12
13
|
import printRoutes from './helpers/printRoutes.js';
|
|
14
|
+
export { BreakingChangesDetectedInOpenApiSpecError } from './helpers/OpenApiSpecDiff.js';
|
|
13
15
|
export default class PsychicBin {
|
|
14
16
|
static async generateController(controllerName, actions) {
|
|
15
17
|
await generateController({
|
|
@@ -59,6 +61,11 @@ export default class PsychicBin {
|
|
|
59
61
|
await new ASTPsychicTypesBuilder().build();
|
|
60
62
|
});
|
|
61
63
|
}
|
|
64
|
+
static openapiDiff() {
|
|
65
|
+
const psychicApp = PsychicApp.getOrFail();
|
|
66
|
+
const openapiConfigsWithCheckDiffs = Object.entries(psychicApp.openapi).filter(([, config]) => config.checkDiffs);
|
|
67
|
+
OpenApiSpecDiff.compare(openapiConfigsWithCheckDiffs);
|
|
68
|
+
}
|
|
62
69
|
static async syncOpenapiTypescriptFiles() {
|
|
63
70
|
DreamCLI.logger.logStartProgress(`syncing openapi types...`);
|
|
64
71
|
// https://rvohealth.atlassian.net/browse/PDTC-8359
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DreamCLI } from '@rvoh/dream/system';
|
|
2
|
-
import PsychicBin from '../bin/index.js';
|
|
2
|
+
import PsychicBin, { BreakingChangesDetectedInOpenApiSpecError } from '../bin/index.js';
|
|
3
3
|
import generateController from '../generate/controller.js';
|
|
4
4
|
import generateSyncEnumsInitializer from '../generate/initializer/syncEnums.js';
|
|
5
5
|
import generateSyncOpenapiTypescriptInitializer from '../generate/initializer/syncOpenapiTypescript.js';
|
|
@@ -196,6 +196,27 @@ export default class PsychicCLI {
|
|
|
196
196
|
await PsychicBin.syncOpenapiJson();
|
|
197
197
|
process.exit();
|
|
198
198
|
});
|
|
199
|
+
program
|
|
200
|
+
.command('diff:openapi')
|
|
201
|
+
.description('compares the current branch open api spec file(s) with the main/master/head branch open api spec file(s)')
|
|
202
|
+
.option('-f', '--fail-on-breaking', 'fail on spec changes that are breaking')
|
|
203
|
+
.action(async (options) => {
|
|
204
|
+
await initializePsychicApp();
|
|
205
|
+
try {
|
|
206
|
+
PsychicBin.openapiDiff();
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
if (error instanceof BreakingChangesDetectedInOpenApiSpecError) {
|
|
210
|
+
if (options.failOnBreaking) {
|
|
211
|
+
console.error(error.message);
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
});
|
|
199
220
|
}
|
|
200
221
|
/**
|
|
201
222
|
* @internal
|
|
@@ -131,7 +131,7 @@ export default class PsychicController {
|
|
|
131
131
|
/**
|
|
132
132
|
* @internal
|
|
133
133
|
*
|
|
134
|
-
* Used for displaying routes when running `
|
|
134
|
+
* Used for displaying routes when running `pnpm psy routes`
|
|
135
135
|
* cli command
|
|
136
136
|
*/
|
|
137
137
|
static get disaplayName() {
|
|
@@ -6,7 +6,7 @@ import UnexpectedUndefined from '../../error/UnexpectedUndefined.js';
|
|
|
6
6
|
import PsychicApp from '../../psychic-app/index.js';
|
|
7
7
|
const devServerProcesses = {};
|
|
8
8
|
const debugEnabled = debuglog('psychic').enabled;
|
|
9
|
-
export async function launchDevServer(key, { port = 3000, cmd = '
|
|
9
|
+
export async function launchDevServer(key, { port = 3000, cmd = 'pnpm client', timeout = 5000 } = {}) {
|
|
10
10
|
if (devServerProcesses[key])
|
|
11
11
|
return;
|
|
12
12
|
if (debugEnabled)
|
|
@@ -87,8 +87,9 @@ function parseAttribute(attribute) {
|
|
|
87
87
|
const [rawAttributeName, rawAttributeType, , enumValues] = attribute.split(':');
|
|
88
88
|
if (!rawAttributeName || !rawAttributeType)
|
|
89
89
|
return null;
|
|
90
|
+
const sanitizedAttrType = camelize(rawAttributeType)?.toLowerCase();
|
|
90
91
|
// Handle belongs_to relationships
|
|
91
|
-
if (
|
|
92
|
+
if (sanitizedAttrType === 'belongsto') {
|
|
92
93
|
// For belongs_to relationships, convert "Ticketing/Ticket" to "ticket"
|
|
93
94
|
const attributeName = camelize(rawAttributeName.split('/').pop());
|
|
94
95
|
return { attributeName, attributeType: 'belongs_to', isArray: false, enumValues };
|
|
@@ -105,8 +106,9 @@ function parseAttribute(attribute) {
|
|
|
105
106
|
return { attributeName, attributeType, isArray, enumValues };
|
|
106
107
|
}
|
|
107
108
|
function processAttributeByType({ attributeType, attributeName, isArray, enumValues, dotNotationVariable, fullyQualifiedModelName, attributeData, }) {
|
|
108
|
-
|
|
109
|
-
|
|
109
|
+
const sanitizedAttributeType = camelize(attributeType).toLowerCase();
|
|
110
|
+
switch (sanitizedAttributeType) {
|
|
111
|
+
case 'belongsto':
|
|
110
112
|
// belongs_to relationships are NOT included in comparable attributes or create/update data
|
|
111
113
|
// They are only tracked for original values in update contexts
|
|
112
114
|
break;
|
|
@@ -8,7 +8,7 @@ export default async function installOpenapiTypescript() {
|
|
|
8
8
|
try {
|
|
9
9
|
await DreamCLI.spawn(cmd);
|
|
10
10
|
}
|
|
11
|
-
catch
|
|
11
|
+
catch {
|
|
12
12
|
console.log(`Failed to install openapi-typescript as a dev dependency. Please make sure the following command succeeds:
|
|
13
13
|
|
|
14
14
|
"${cmd}"
|
|
@@ -298,6 +298,7 @@ function associationOpenapi(attribute, DataTypeForOpenapi, alreadyExtractedDesce
|
|
|
298
298
|
if (associatedClass === undefined) {
|
|
299
299
|
let serializerCheck;
|
|
300
300
|
try {
|
|
301
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
301
302
|
;
|
|
302
303
|
DataTypeForOpenapi?.prototype?.serializers;
|
|
303
304
|
}
|
|
@@ -57,7 +57,7 @@ export default class OpenapiAppRenderer {
|
|
|
57
57
|
openapi: '3.1.0',
|
|
58
58
|
info: {
|
|
59
59
|
version: openapiConfig?.info?.version || 'unknown version',
|
|
60
|
-
title: openapiConfig?.info?.title ||
|
|
60
|
+
title: openapiConfig?.info?.title || `${psychicApp.appName} | ${openapiName}`,
|
|
61
61
|
description: openapiConfig?.info?.description || 'The autogenerated openapi spec for your app',
|
|
62
62
|
},
|
|
63
63
|
paths: {},
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { DefaultPsychicOpenapiOptions } from '../../psychic-app/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Interface to hold the result of a comparison
|
|
4
|
+
* between the current local OpenAPI specification and the head branch
|
|
5
|
+
* for a given OpenAPI file
|
|
6
|
+
*/
|
|
7
|
+
export interface ComparisonResult {
|
|
8
|
+
file: string;
|
|
9
|
+
hasChanges: boolean;
|
|
10
|
+
breaking: string[];
|
|
11
|
+
changelog: string[];
|
|
12
|
+
error?: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Interface to hold the configuration for oasdiff
|
|
16
|
+
*/
|
|
17
|
+
export interface OasDiffConfig {
|
|
18
|
+
command: string;
|
|
19
|
+
baseArgs: string[];
|
|
20
|
+
headBranch: string;
|
|
21
|
+
}
|
|
22
|
+
export type PsychicOpenapiConfig = DefaultPsychicOpenapiOptions;
|
|
23
|
+
/**
|
|
24
|
+
* Class-based OpenAPI specification diff tool
|
|
25
|
+
*
|
|
26
|
+
* Compares current OpenAPI specs against the head branch using oasdiff
|
|
27
|
+
*
|
|
28
|
+
* Example usages:
|
|
29
|
+
*
|
|
30
|
+
* Instance-based usage
|
|
31
|
+
* const diffTool = new OpenApiSpecDiff()
|
|
32
|
+
* diffTool.compare(openapiConfigs)
|
|
33
|
+
*
|
|
34
|
+
* Factory method usage
|
|
35
|
+
* const diffTool = OpenApiSpecDiff.create()
|
|
36
|
+
* diffTool.compare(openapiConfigs)
|
|
37
|
+
*
|
|
38
|
+
* Static method usage (backward compatibility)
|
|
39
|
+
* OpenApiSpecDiff.compare(openapiConfigs)
|
|
40
|
+
*/
|
|
41
|
+
export declare class OpenApiSpecDiff {
|
|
42
|
+
/**
|
|
43
|
+
* The configuration for the oasdiff command
|
|
44
|
+
*/
|
|
45
|
+
private oasdiffConfig?;
|
|
46
|
+
/**
|
|
47
|
+
* Compares a list of OpenAPI specifications between the current branch and the head branch.
|
|
48
|
+
*
|
|
49
|
+
* Uses `oasdiff` under the hood to detect breaking and non-breaking changes for each file,
|
|
50
|
+
* helping you review and validate API updates with confidence before merging.
|
|
51
|
+
*
|
|
52
|
+
* This tool only runs for configurations where `checkDiffs` is enabled.
|
|
53
|
+
*
|
|
54
|
+
* @param openapiConfigs - An array of tuples containing the OpenAPI file name and its configuration.
|
|
55
|
+
* @example
|
|
56
|
+
* ```ts
|
|
57
|
+
* const openapiConfigs: [string, PsychicOpenapiConfig][] = [
|
|
58
|
+
* ['openapi', { outputFilepath: 'openapi.json' }],
|
|
59
|
+
* ]
|
|
60
|
+
* OpenApiSpecDiff.compare(openapiConfigs)
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
compare(openapiConfigs: [string, PsychicOpenapiConfig][]): void;
|
|
64
|
+
/**
|
|
65
|
+
* Checks if oasdiff is installed locally
|
|
66
|
+
*/
|
|
67
|
+
private hasOasDiffInstalled;
|
|
68
|
+
/**
|
|
69
|
+
* Fetch the head branch of a remote using git remote show origin
|
|
70
|
+
* default to main if not set
|
|
71
|
+
*/
|
|
72
|
+
private getHeadBranch;
|
|
73
|
+
/**
|
|
74
|
+
* Validates that oasdiff is installed and builds the oasdiff config
|
|
75
|
+
*/
|
|
76
|
+
private getOasDiffConfig;
|
|
77
|
+
/**
|
|
78
|
+
* Runs oasdiff command and returns the output
|
|
79
|
+
*/
|
|
80
|
+
private runOasDiffCommand;
|
|
81
|
+
/**
|
|
82
|
+
* Compares two OpenAPI files using oasdiff
|
|
83
|
+
*/
|
|
84
|
+
private compareSpecs;
|
|
85
|
+
/**
|
|
86
|
+
* Creates a temporary file path for the head branch content
|
|
87
|
+
*/
|
|
88
|
+
private createTempFilePath;
|
|
89
|
+
/**
|
|
90
|
+
* Retrieves head branch content for a file
|
|
91
|
+
*/
|
|
92
|
+
private getHeadBranchContent;
|
|
93
|
+
/**
|
|
94
|
+
* Compares a single OpenAPI file against head branch
|
|
95
|
+
*/
|
|
96
|
+
private compareConfig;
|
|
97
|
+
/**
|
|
98
|
+
* Process and display the comparison results
|
|
99
|
+
*/
|
|
100
|
+
private processResults;
|
|
101
|
+
/**
|
|
102
|
+
* Log error for a comparison result
|
|
103
|
+
*/
|
|
104
|
+
private logError;
|
|
105
|
+
/**
|
|
106
|
+
* Log changes for a comparison result
|
|
107
|
+
*/
|
|
108
|
+
private logChanges;
|
|
109
|
+
/**
|
|
110
|
+
* Log breaking changes for a comparison result
|
|
111
|
+
*/
|
|
112
|
+
private logBreakingChanges;
|
|
113
|
+
/**
|
|
114
|
+
* Log changelog for a comparison result
|
|
115
|
+
*/
|
|
116
|
+
private logChangelog;
|
|
117
|
+
/**
|
|
118
|
+
* Log no changes for a comparison result
|
|
119
|
+
*/
|
|
120
|
+
private logNoChanges;
|
|
121
|
+
/**
|
|
122
|
+
* Log final summary and handle exit conditions
|
|
123
|
+
*/
|
|
124
|
+
private logSummary;
|
|
125
|
+
/**
|
|
126
|
+
* Static factory method for convenience
|
|
127
|
+
*/
|
|
128
|
+
static create(): OpenApiSpecDiff;
|
|
129
|
+
/**
|
|
130
|
+
* Static method to maintain compatibility with functional approach
|
|
131
|
+
*/
|
|
132
|
+
static compare(openapiConfigs: [string, PsychicOpenapiConfig][]): void;
|
|
133
|
+
}
|
|
134
|
+
export declare class BreakingChangesDetectedInOpenApiSpecError extends Error {
|
|
135
|
+
private readonly oasdiffConfig;
|
|
136
|
+
constructor(oasdiffConfig: OasDiffConfig);
|
|
137
|
+
get message(): string;
|
|
138
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { BreakingChangesDetectedInOpenApiSpecError } from './helpers/OpenApiSpecDiff.js';
|
|
1
2
|
export default class PsychicBin {
|
|
2
3
|
static generateController(controllerName: string, actions: string[]): Promise<void>;
|
|
3
4
|
static generateResource(route: string, fullyQualifiedModelName: string, columnsWithTypes: string[], options: {
|
|
@@ -14,6 +15,7 @@ export default class PsychicBin {
|
|
|
14
15
|
}): Promise<void>;
|
|
15
16
|
static postSync(): Promise<void>;
|
|
16
17
|
static syncTypes(): Promise<void>;
|
|
18
|
+
static openapiDiff(): void;
|
|
17
19
|
static syncOpenapiTypescriptFiles(): Promise<void>;
|
|
18
20
|
static syncOpenapiJson(): Promise<void>;
|
|
19
21
|
static syncRoutes(): Promise<void>;
|
|
@@ -491,6 +491,34 @@ interface PsychicOpenapiBaseOptions {
|
|
|
491
491
|
* ```
|
|
492
492
|
*/
|
|
493
493
|
validate?: OpenapiValidateOption;
|
|
494
|
+
/**
|
|
495
|
+
* Enables automatic comparison of OpenAPI specifications between the current
|
|
496
|
+
* working branch and the main/master/head branch.
|
|
497
|
+
*
|
|
498
|
+
* When set to `true`, Psychic will perform a diff check between the OpenAPI spec
|
|
499
|
+
* in the current branch and the one in the main/master/head branch. This is done using the
|
|
500
|
+
* `OpenApiSpecDiff` tool, which analyzes structural and semantic differences
|
|
501
|
+
* between the two specs.
|
|
502
|
+
*
|
|
503
|
+
* This feature is useful for catching unintended changes to your API contract
|
|
504
|
+
* before they are merged, especially breaking changes that could affect downstream
|
|
505
|
+
* consumers or integrations.
|
|
506
|
+
*
|
|
507
|
+
* Typical use cases include CI/CD validation, pull request checks, or local
|
|
508
|
+
* development sanity checks.
|
|
509
|
+
*
|
|
510
|
+
* Example usage:
|
|
511
|
+
* ```ts
|
|
512
|
+
* psy.set('openapi', {
|
|
513
|
+
* ...
|
|
514
|
+
* checkDiffs: true,
|
|
515
|
+
* ...
|
|
516
|
+
* });
|
|
517
|
+
* ```
|
|
518
|
+
*
|
|
519
|
+
|
|
520
|
+
*/
|
|
521
|
+
checkDiffs?: boolean;
|
|
494
522
|
}
|
|
495
523
|
interface PsychicOpenapiInfo {
|
|
496
524
|
version: string;
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@rvoh/psychic",
|
|
4
4
|
"description": "Typescript web framework",
|
|
5
|
-
"version": "2.
|
|
5
|
+
"version": "2.2.0",
|
|
6
6
|
"author": "RVOHealth",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
@@ -48,28 +48,29 @@
|
|
|
48
48
|
"dist/**/*"
|
|
49
49
|
],
|
|
50
50
|
"scripts": {
|
|
51
|
-
"client": "
|
|
52
|
-
"client:fspec": "VITE_PSYCHIC_ENV=test BROWSER=none
|
|
53
|
-
"psy": "NODE_ENV=${NODE_ENV:-test}
|
|
51
|
+
"client": "cd client && pnpm start",
|
|
52
|
+
"client:fspec": "cd client && VITE_PSYCHIC_ENV=test BROWSER=none pnpm start",
|
|
53
|
+
"psy": "NODE_ENV=${NODE_ENV:-test} pnpm psyts",
|
|
54
54
|
"psyjs": "node ./dist/test-app/src/cli/index.js",
|
|
55
55
|
"psyts": "NODE_ENV=${NODE_ENV:-test} tsx ./test-app/src/cli/index.ts",
|
|
56
56
|
"gpsy": "tsx ./global-cli/main.ts",
|
|
57
57
|
"build": "echo \"building cjs...\" && rm -rf dist && npx tsc -p ./tsconfig.cjs.build.json && echo \"building esm...\" && npx tsc -p ./tsconfig.esm.build.json",
|
|
58
58
|
"build:test-app": "rm -rf dist && echo \"building test app to esm...\" && npx tsc -p ./tsconfig.esm.build.test-app.json && echo \"building test app to cjs...\" && npx tsc -p ./tsconfig.cjs.build.test-app.json",
|
|
59
|
-
"types:esm:trace": "rm -rf dist && npx tsc -p ./tsconfig.esm.build.json --generateTrace ./typetrace --diagnostics &&
|
|
59
|
+
"types:esm:trace": "rm -rf dist && npx tsc -p ./tsconfig.esm.build.json --generateTrace ./typetrace --diagnostics && pnpm analyze-trace ./typetrace --skipMillis 100 --forceMillis 300",
|
|
60
60
|
"dev": "nodemon --quiet --no-stdin",
|
|
61
61
|
"console": "tsx ./test-app/src/conf/repl.ts",
|
|
62
62
|
"uspec": "vitest --config ./spec/unit/vite.config.ts",
|
|
63
63
|
"fspec": "vitest run --config=./spec/features/vite.config.ts",
|
|
64
|
-
"format": "
|
|
65
|
-
"lint": "
|
|
66
|
-
"prepack": "
|
|
64
|
+
"format": "pnpm prettier . --write",
|
|
65
|
+
"lint": "pnpm eslint --no-warn-ignored \"src/**/*.ts\" \"spec/**/*.ts\" \"test-app/**/*.ts\" && pnpm prettier . --check",
|
|
66
|
+
"prepack": "pnpm build"
|
|
67
67
|
},
|
|
68
68
|
"dependencies": {
|
|
69
69
|
"@types/cookie-parser": "^1.4.8",
|
|
70
70
|
"@types/cors": "^2.8.17",
|
|
71
71
|
"ajv": "^8.17.1",
|
|
72
72
|
"ajv-formats": "^3.0.1",
|
|
73
|
+
"body-parser": "^2.2.1",
|
|
73
74
|
"commander": "^12.1.0",
|
|
74
75
|
"cookie-parser": "^1.4.7",
|
|
75
76
|
"cors": "^2.8.5",
|
|
@@ -85,11 +86,12 @@
|
|
|
85
86
|
"openapi-typescript": "^7.8.0"
|
|
86
87
|
},
|
|
87
88
|
"devDependencies": {
|
|
88
|
-
"@eslint/js": "^9.
|
|
89
|
+
"@eslint/js": "^9.39.1",
|
|
89
90
|
"@jest-mock/express": "^3.0.0",
|
|
90
91
|
"@rvoh/dream": "^2.0.3",
|
|
91
92
|
"@rvoh/dream-spec-helpers": "^2.0.0",
|
|
92
93
|
"@rvoh/psychic-spec-helpers": "^2.0.0",
|
|
94
|
+
"@types/body-parser": "^1.19.6",
|
|
93
95
|
"@types/express": "^5.0.6",
|
|
94
96
|
"@types/express-session": "^1.18.2",
|
|
95
97
|
"@types/node": "^22.17.1",
|
|
@@ -97,13 +99,14 @@
|
|
|
97
99
|
"@types/passport-local": "^1",
|
|
98
100
|
"@types/pg": "^8.11.8",
|
|
99
101
|
"@types/supertest": "^6.0.3",
|
|
102
|
+
"@typescript-eslint/parser": "^8.48.1",
|
|
100
103
|
"@typescript/analyze-trace": "^0.10.1",
|
|
101
|
-
"eslint": "^9.
|
|
104
|
+
"eslint": "^9.39.1",
|
|
102
105
|
"express": "^5.2.1",
|
|
103
106
|
"express-session": "^1.18.2",
|
|
104
107
|
"jsdom": "^26.1.0",
|
|
105
108
|
"kysely": "^0.28.5",
|
|
106
|
-
"kysely-codegen": "~0.
|
|
109
|
+
"kysely-codegen": "~0.19.0",
|
|
107
110
|
"luxon-jest-matchers": "^0.1.14",
|
|
108
111
|
"nodemon": "^3.1.11",
|
|
109
112
|
"openapi-typescript": "^7.8.0",
|
|
@@ -117,9 +120,9 @@
|
|
|
117
120
|
"tsx": "^4.19.3",
|
|
118
121
|
"typedoc": "^0.26.6",
|
|
119
122
|
"typescript": "^5.5.4",
|
|
120
|
-
"typescript-eslint": "
|
|
123
|
+
"typescript-eslint": "^8.48.1",
|
|
121
124
|
"vitest": "^4.0.9",
|
|
122
125
|
"winston": "^3.14.2"
|
|
123
126
|
},
|
|
124
|
-
"packageManager": "
|
|
127
|
+
"packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a"
|
|
125
128
|
}
|