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.
@@ -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 || `HTTP_${this.requestMethod.toUpperCase()}`;
31
+ return operationId
32
+ ? sanitizeIdentifier(operationId)
33
+ : `HTTP_${this.requestMethod.toUpperCase()}`;
21
34
  }
22
35
  names() {
23
- return super.names(`HTTP_${this.requestMethod.toUpperCase()}`);
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": "1.6.0",
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.11",
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.23",
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.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",