counterfact 1.6.0 → 2.0.1
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/bin/counterfact.js
CHANGED
|
@@ -11,6 +11,7 @@ import open from "open";
|
|
|
11
11
|
|
|
12
12
|
import { counterfact } from "../dist/app.js";
|
|
13
13
|
import { pathsToRoutes } from "../dist/migrate/paths-to-routes.js";
|
|
14
|
+
import { updateRouteTypes } from "../dist/migrate/update-route-types.js";
|
|
14
15
|
|
|
15
16
|
const MIN_NODE_VERSION = 17;
|
|
16
17
|
|
|
@@ -172,6 +173,7 @@ async function main(source, destination) {
|
|
|
172
173
|
debug("loading counterfact (%o)", config);
|
|
173
174
|
|
|
174
175
|
let didMigrate = false;
|
|
176
|
+
let didMigrateRouteTypes = false;
|
|
175
177
|
|
|
176
178
|
// eslint-disable-next-line n/no-sync
|
|
177
179
|
if (fs.existsSync(nodePath.join(config.basePath, "paths"))) {
|
|
@@ -193,6 +195,14 @@ async function main(source, destination) {
|
|
|
193
195
|
|
|
194
196
|
debug("loaded counterfact", config);
|
|
195
197
|
|
|
198
|
+
// Migrate route type imports if needed
|
|
199
|
+
debug("checking if route type migration is needed");
|
|
200
|
+
didMigrateRouteTypes = await updateRouteTypes(
|
|
201
|
+
config.basePath,
|
|
202
|
+
config.openApiPath,
|
|
203
|
+
);
|
|
204
|
+
debug("route type migration check complete: %s", didMigrateRouteTypes);
|
|
205
|
+
|
|
196
206
|
const watchMessage = createWatchMessage(config);
|
|
197
207
|
|
|
198
208
|
const introduction = [
|
|
@@ -243,6 +253,25 @@ async function main(source, destination) {
|
|
|
243
253
|
);
|
|
244
254
|
process.stdout.write("*******************************\n\n\n");
|
|
245
255
|
}
|
|
256
|
+
|
|
257
|
+
if (didMigrateRouteTypes) {
|
|
258
|
+
process.stdout.write("\n\n\n*******************************\n");
|
|
259
|
+
process.stdout.write("MIGRATING ROUTE TYPE IMPORTS\n\n");
|
|
260
|
+
process.stdout.write(
|
|
261
|
+
"Operation types now use operationId from your OpenAPI spec when available.\n",
|
|
262
|
+
);
|
|
263
|
+
process.stdout.write(
|
|
264
|
+
"Your route files have been automatically updated to use the new type names.\n",
|
|
265
|
+
);
|
|
266
|
+
process.stdout.write(
|
|
267
|
+
"Example: 'HTTP_GET' may now be 'getPetById' if operationId is defined.\n",
|
|
268
|
+
);
|
|
269
|
+
process.stdout.write(
|
|
270
|
+
"Please review the changes and report any issues to:\n",
|
|
271
|
+
);
|
|
272
|
+
process.stdout.write("https://github.com/pmcelhaney/counterfact/issues\n");
|
|
273
|
+
process.stdout.write("*******************************\n\n\n");
|
|
274
|
+
}
|
|
246
275
|
}
|
|
247
276
|
|
|
248
277
|
program
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import createDebug from "debug";
|
|
4
|
+
import { OperationTypeCoder } from "../typescript-generator/operation-type-coder.js";
|
|
5
|
+
import { Specification } from "../typescript-generator/specification.js";
|
|
6
|
+
const debug = createDebug("counterfact:migrate:update-route-types");
|
|
7
|
+
const HTTP_METHODS = [
|
|
8
|
+
"GET",
|
|
9
|
+
"POST",
|
|
10
|
+
"PUT",
|
|
11
|
+
"DELETE",
|
|
12
|
+
"PATCH",
|
|
13
|
+
"HEAD",
|
|
14
|
+
"OPTIONS",
|
|
15
|
+
];
|
|
16
|
+
/**
|
|
17
|
+
* Converts an OpenAPI path to a file system path
|
|
18
|
+
* e.g., "/hello/{name}" -> "hello/{name}"
|
|
19
|
+
*/
|
|
20
|
+
function openApiPathToFilePath(openApiPath) {
|
|
21
|
+
if (openApiPath === "/") {
|
|
22
|
+
return "index";
|
|
23
|
+
}
|
|
24
|
+
return openApiPath.startsWith("/") ? openApiPath.slice(1) : openApiPath;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Builds a mapping of route file paths to their operation type names per method
|
|
28
|
+
* @param {Specification} specification - The OpenAPI specification
|
|
29
|
+
* @returns {Promise<Map<string, Map<string, string>>>} - Map of filePath -> Map of method -> typeName
|
|
30
|
+
*/
|
|
31
|
+
async function buildTypeNameMapping(specification) {
|
|
32
|
+
debug("building type name mapping from specification");
|
|
33
|
+
const mapping = new Map();
|
|
34
|
+
try {
|
|
35
|
+
const paths = specification.getRequirement("#/paths");
|
|
36
|
+
if (!paths) {
|
|
37
|
+
debug("no paths found in specification");
|
|
38
|
+
return mapping;
|
|
39
|
+
}
|
|
40
|
+
const securityRequirement = specification.getRequirement("#/components/securitySchemes");
|
|
41
|
+
const securitySchemes = Object.values(securityRequirement?.data ?? {});
|
|
42
|
+
paths.forEach((pathDefinition, openApiPath) => {
|
|
43
|
+
const filePath = openApiPathToFilePath(openApiPath);
|
|
44
|
+
const methodMap = new Map();
|
|
45
|
+
pathDefinition.forEach((operation, requestMethod) => {
|
|
46
|
+
// Skip if not a standard HTTP method
|
|
47
|
+
if (!HTTP_METHODS.includes(requestMethod.toUpperCase())) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
// Create the type coder to get the correct type name
|
|
51
|
+
const typeCoder = new OperationTypeCoder(operation, requestMethod, securitySchemes);
|
|
52
|
+
// Get the type name (first from the names generator)
|
|
53
|
+
const typeName = typeCoder.names().next().value;
|
|
54
|
+
methodMap.set(requestMethod.toUpperCase(), typeName);
|
|
55
|
+
debug("mapped %s %s -> %s", requestMethod.toUpperCase(), openApiPath, typeName);
|
|
56
|
+
});
|
|
57
|
+
if (methodMap.size > 0) {
|
|
58
|
+
mapping.set(filePath, methodMap);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
debug("built mapping for %d routes", mapping.size);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
debug("error building type name mapping: %o", error);
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
return mapping;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Checks if a route file needs migration by looking for old-style HTTP_ imports
|
|
71
|
+
* @param {string} content - The file content
|
|
72
|
+
* @returns {boolean}
|
|
73
|
+
*/
|
|
74
|
+
function needsMigration(content) {
|
|
75
|
+
const methodAlternation = HTTP_METHODS.map((method) => method.toUpperCase()).join("|");
|
|
76
|
+
const pattern = new RegExp(`import\\s+type\\s+\\{[^}]*HTTP_(?:${methodAlternation})[^}]*\\}`, "iu");
|
|
77
|
+
return pattern.test(content);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Updates a single route file with the correct type names
|
|
81
|
+
* @param {string} filePath - Absolute path to the route file
|
|
82
|
+
* @param {Map<string, string>} methodToTypeName - Map of HTTP method to type name
|
|
83
|
+
* @returns {Promise<boolean>} - True if file was updated
|
|
84
|
+
*/
|
|
85
|
+
async function updateRouteFile(filePath, methodToTypeName) {
|
|
86
|
+
debug("processing route file: %s", filePath);
|
|
87
|
+
let content = await fs.readFile(filePath, "utf8");
|
|
88
|
+
// Check if migration is needed
|
|
89
|
+
if (!needsMigration(content)) {
|
|
90
|
+
debug("file does not need migration: %s", filePath);
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
let modified = false;
|
|
94
|
+
// Build a map of old type names to new type names found in this file
|
|
95
|
+
const replacements = new Map();
|
|
96
|
+
// Find all import statements with HTTP_ patterns
|
|
97
|
+
const importRegex = /import\s+type\s+\{(?<types>[^}]+)\}\s+from\s+["'](?<source>[^"']+)["'];?/gu;
|
|
98
|
+
let importMatch;
|
|
99
|
+
while ((importMatch = importRegex.exec(content)) !== null) {
|
|
100
|
+
const importedTypes = importMatch.groups.types
|
|
101
|
+
.split(",")
|
|
102
|
+
.map((t) => t.trim())
|
|
103
|
+
.filter((t) => t.length > 0);
|
|
104
|
+
for (const importedType of importedTypes) {
|
|
105
|
+
// Check if this is an HTTP_ type
|
|
106
|
+
const httpMethodMatch = importedType.match(new RegExp(`^HTTP_(?<method>${HTTP_METHODS.join("|")})$`, "u"));
|
|
107
|
+
if (httpMethodMatch) {
|
|
108
|
+
const method = httpMethodMatch.groups.method;
|
|
109
|
+
const newTypeName = methodToTypeName.get(method);
|
|
110
|
+
if (newTypeName && newTypeName !== importedType) {
|
|
111
|
+
replacements.set(importedType, newTypeName);
|
|
112
|
+
debug("will replace %s with %s", importedType, newTypeName);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (replacements.size === 0) {
|
|
118
|
+
debug("no replacements needed for: %s", filePath);
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
// Apply replacements
|
|
122
|
+
for (const [oldName, newName] of replacements.entries()) {
|
|
123
|
+
// Replace in import statement
|
|
124
|
+
const importPattern = new RegExp(`(import\\s+type\\s+\\{[^}]*\\b)${oldName}(\\b[^}]*\\}\\s+from)`, "g");
|
|
125
|
+
content = content.replace(importPattern, `$1${newName}$2`);
|
|
126
|
+
// Replace in export statement (e.g., "export const GET: HTTP_GET")
|
|
127
|
+
// Match the method from the old type name
|
|
128
|
+
const methodMatch = oldName.match(new RegExp(`^HTTP_(?<method>${HTTP_METHODS.join("|")})$`, "u"));
|
|
129
|
+
if (methodMatch) {
|
|
130
|
+
const method = methodMatch.groups.method;
|
|
131
|
+
const exportPattern = new RegExp(`(export\\s+const\\s+${method}\\s*:\\s*)${oldName}(\\b)`, "g");
|
|
132
|
+
content = content.replace(exportPattern, `$1${newName}$2`);
|
|
133
|
+
}
|
|
134
|
+
modified = true;
|
|
135
|
+
}
|
|
136
|
+
if (modified) {
|
|
137
|
+
await fs.writeFile(filePath, content, "utf8");
|
|
138
|
+
debug("updated file: %s", filePath);
|
|
139
|
+
}
|
|
140
|
+
return modified;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Recursively processes route files in a directory
|
|
144
|
+
* @param {string} routesDir - Path to routes directory
|
|
145
|
+
* @param {string} currentPath - Current subdirectory being processed
|
|
146
|
+
* @param {Map<string, Map<string, string>>} mapping - Type name mapping
|
|
147
|
+
* @returns {Promise<number>} - Number of files updated
|
|
148
|
+
*/
|
|
149
|
+
async function processRouteDirectory(routesDir, currentPath, mapping) {
|
|
150
|
+
let updatedCount = 0;
|
|
151
|
+
try {
|
|
152
|
+
const entries = await fs.readdir(path.join(routesDir, currentPath), {
|
|
153
|
+
withFileTypes: true,
|
|
154
|
+
});
|
|
155
|
+
for (const entry of entries) {
|
|
156
|
+
const relativePath = path.join(currentPath, entry.name);
|
|
157
|
+
const absolutePath = path.join(routesDir, relativePath);
|
|
158
|
+
if (entry.isDirectory()) {
|
|
159
|
+
// Recursively process subdirectories
|
|
160
|
+
updatedCount += await processRouteDirectory(routesDir, relativePath, mapping);
|
|
161
|
+
}
|
|
162
|
+
else if (entry.name.endsWith(".ts") && entry.name !== "_.context.ts") {
|
|
163
|
+
// Process TypeScript route files (skip context files)
|
|
164
|
+
const routePath = relativePath
|
|
165
|
+
.replace(/\.ts$/, "")
|
|
166
|
+
.replaceAll("\\", "/");
|
|
167
|
+
const methodMap = mapping.get(routePath);
|
|
168
|
+
if (methodMap) {
|
|
169
|
+
const wasUpdated = await updateRouteFile(absolutePath, methodMap);
|
|
170
|
+
if (wasUpdated) {
|
|
171
|
+
updatedCount++;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (error.code !== "ENOENT") {
|
|
179
|
+
debug("error processing directory %s: %o", currentPath, error);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return updatedCount;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Checks if any route files need migration
|
|
186
|
+
* @param {string} routesDir - Path to routes directory
|
|
187
|
+
* @returns {Promise<boolean>}
|
|
188
|
+
*/
|
|
189
|
+
async function checkIfMigrationNeeded(routesDir) {
|
|
190
|
+
try {
|
|
191
|
+
const entries = await fs.readdir(routesDir, { withFileTypes: true });
|
|
192
|
+
for (const entry of entries) {
|
|
193
|
+
if (entry.name.endsWith(".ts") && entry.name !== "_.context.ts") {
|
|
194
|
+
const content = await fs.readFile(path.join(routesDir, entry.name), "utf8");
|
|
195
|
+
if (needsMigration(content)) {
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
else if (entry.isDirectory() && entry.name !== "node_modules") {
|
|
200
|
+
// Recursively check subdirectories
|
|
201
|
+
const subDirPath = path.join(routesDir, entry.name);
|
|
202
|
+
const found = await checkIfMigrationNeeded(subDirPath);
|
|
203
|
+
if (found) {
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
debug("error checking for migration need: %o", error);
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Main migration function - updates route type imports to use new naming convention
|
|
216
|
+
* @param {string} basePath - Base path where routes and types are located
|
|
217
|
+
* @param {string} openApiPath - Path or URL to OpenAPI specification
|
|
218
|
+
* @returns {Promise<boolean>} - True if migration was performed
|
|
219
|
+
*/
|
|
220
|
+
export async function updateRouteTypes(basePath, openApiPath) {
|
|
221
|
+
debug("starting route type migration for base path: %s", basePath);
|
|
222
|
+
// Skip if running without OpenAPI spec
|
|
223
|
+
if (openApiPath === "_") {
|
|
224
|
+
debug("skipping migration - no OpenAPI spec provided");
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
const routesDir = path.join(basePath, "routes");
|
|
228
|
+
try {
|
|
229
|
+
// Check if routes directory exists
|
|
230
|
+
await fs.access(routesDir);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
debug("routes directory does not exist: %s", routesDir);
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
// Quick check if migration is needed
|
|
237
|
+
const migrationNeeded = await checkIfMigrationNeeded(routesDir);
|
|
238
|
+
if (!migrationNeeded) {
|
|
239
|
+
debug("no migration needed - no old-style HTTP_ imports found");
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
// Load the OpenAPI specification
|
|
244
|
+
debug("loading OpenAPI specification from: %s", openApiPath);
|
|
245
|
+
const specification = await Specification.fromFile(openApiPath);
|
|
246
|
+
// Build the mapping of paths to type names
|
|
247
|
+
const mapping = await buildTypeNameMapping(specification);
|
|
248
|
+
if (mapping.size === 0) {
|
|
249
|
+
debug("no routes found in specification");
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
// Process all route files
|
|
253
|
+
debug("processing route files in: %s", routesDir);
|
|
254
|
+
const updatedCount = await processRouteDirectory(routesDir, "", mapping);
|
|
255
|
+
debug("migration complete - updated %d files", updatedCount);
|
|
256
|
+
return updatedCount > 0;
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
debug("error during migration: %o", error);
|
|
260
|
+
process.stderr.write(`Warning: Could not migrate route types: ${error.message}\n`);
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -6,6 +6,17 @@ import { ResponsesTypeCoder } from "./responses-type-coder.js";
|
|
|
6
6
|
import { SchemaTypeCoder } from "./schema-type-coder.js";
|
|
7
7
|
import { TypeCoder } from "./type-coder.js";
|
|
8
8
|
import { ParameterExportTypeCoder } from "./parameter-export-type-coder.js";
|
|
9
|
+
function sanitizeIdentifier(value) {
|
|
10
|
+
// Treat any run of non-identifier characters as a camelCase separator
|
|
11
|
+
let result = value.replaceAll(/[^\w$]+(?<next>.)/gu, (_, char) => char.toUpperCase());
|
|
12
|
+
// Strip any trailing non-identifier characters (no following char to capitalize)
|
|
13
|
+
result = result.replaceAll(/[^\w$]/gu, "");
|
|
14
|
+
// If the identifier starts with a digit, prefix with an underscore
|
|
15
|
+
if (/^\d/u.test(result)) {
|
|
16
|
+
result = `_${result}`;
|
|
17
|
+
}
|
|
18
|
+
return result || "_";
|
|
19
|
+
}
|
|
9
20
|
export class OperationTypeCoder extends TypeCoder {
|
|
10
21
|
constructor(requirement, requestMethod, securitySchemes = []) {
|
|
11
22
|
super(requirement);
|
|
@@ -17,10 +28,12 @@ export class OperationTypeCoder extends TypeCoder {
|
|
|
17
28
|
}
|
|
18
29
|
getOperationBaseName() {
|
|
19
30
|
const operationId = this.requirement.get("operationId")?.data;
|
|
20
|
-
return operationId
|
|
31
|
+
return operationId
|
|
32
|
+
? sanitizeIdentifier(operationId)
|
|
33
|
+
: `HTTP_${this.requestMethod.toUpperCase()}`;
|
|
21
34
|
}
|
|
22
35
|
names() {
|
|
23
|
-
return super.names(
|
|
36
|
+
return super.names(this.getOperationBaseName());
|
|
24
37
|
}
|
|
25
38
|
exportParameterType(script, parameterKind, inlineType, baseName, modulePath) {
|
|
26
39
|
if (inlineType === "never") {
|
|
@@ -18,7 +18,7 @@ export class SchemaTypeCoder extends TypeCoder {
|
|
|
18
18
|
const { data } = this.requirement;
|
|
19
19
|
const properties = Object.keys(data.properties ?? {}).map((name) => {
|
|
20
20
|
const property = this.requirement.get("properties").get(name);
|
|
21
|
-
const isRequired = data.required?.includes(name) || property.data.required;
|
|
21
|
+
const isRequired = data.required?.includes(name) || property.data.required === true;
|
|
22
22
|
const optionalFlag = isRequired ? "" : "?";
|
|
23
23
|
return `"${name}"${optionalFlag}: ${new SchemaTypeCoder(property).write(script)}`;
|
|
24
24
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "counterfact",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"description": "Generate a TypeScript-based mock server from an OpenAPI spec in seconds — with stateful routes, hot reload, and REPL support.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/app.js",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"@stryker-mutator/core": "9.5.1",
|
|
82
82
|
"@stryker-mutator/jest-runner": "9.5.1",
|
|
83
83
|
"@stryker-mutator/typescript-checker": "9.5.1",
|
|
84
|
-
"@swc/core": "1.15.
|
|
84
|
+
"@swc/core": "1.15.17",
|
|
85
85
|
"@swc/jest": "0.2.39",
|
|
86
86
|
"@testing-library/dom": "10.4.1",
|
|
87
87
|
"@types/debug": "^4.1.12",
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
"@types/koa-bodyparser": "4.3.13",
|
|
92
92
|
"@types/koa-proxy": "1.0.8",
|
|
93
93
|
"@types/koa-static": "4.0.4",
|
|
94
|
-
"@types/lodash": "4.17.
|
|
94
|
+
"@types/lodash": "4.17.24",
|
|
95
95
|
"@typescript-eslint/eslint-plugin": "^8.53.0",
|
|
96
96
|
"@typescript-eslint/parser": "^8.53.0",
|
|
97
97
|
"copyfiles": "2.4.1",
|
|
@@ -134,7 +134,7 @@
|
|
|
134
134
|
"js-yaml": "4.1.1",
|
|
135
135
|
"json-schema-faker": "0.5.9",
|
|
136
136
|
"jsonwebtoken": "9.0.3",
|
|
137
|
-
"koa": "3.1.
|
|
137
|
+
"koa": "3.1.2",
|
|
138
138
|
"koa-bodyparser": "4.4.1",
|
|
139
139
|
"koa-proxies": "0.12.4",
|
|
140
140
|
"koa2-swagger-ui": "5.12.0",
|