Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,9 @@ npx counterfact@latest my-api.yaml api --generate-types

## Will regenerating overwrite my changes?

No. Counterfact only writes files that don't already exist. Your custom route logic is safe when you regenerate after a spec update. New routes are scaffolded; existing ones are left alone.
No. Counterfact never overwrites an existing route file. Your custom route logic is always preserved.

If you add a new HTTP method to an existing path in your spec (for example adding `POST` to a route that already has `GET`), Counterfact **appends** the new handler stub to the bottom of the existing file and inserts its `import type` statement after the existing imports. All pre-existing code in the file is left untouched.

---

Expand Down
173 changes: 162 additions & 11 deletions src/typescript-generator/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,11 @@ export class Repository {
/**
* Waits for all scripts to finish, then writes each one to disk.
*
* Route files (`routes/…`) are never overwritten if they already exist on
* disk, preserving user edits. Type files (`types/…`) are always
* overwritten.
* Route files (`routes/…`) are never fully overwritten if they already exist
* on disk, preserving user edits. However, if the generated script contains
* HTTP-method handler exports that are absent from the existing file, those
* new exports (and their `import type` statements) are appended to the file.
* Type files (`types/…`) are always overwritten.
*
* @param destination - Absolute path to the output root directory.
* @param options - Controls which artefacts are written.
Expand Down Expand Up @@ -138,16 +140,24 @@ export class Repository {
const shouldWriteRoutes = routes && path.startsWith("routes");
const shouldWriteTypes = types && !path.startsWith("routes");

if (
shouldWriteRoutes &&
(await fs
if (shouldWriteRoutes) {
const fileExists = await fs
.stat(fullPath)
.then((stat) => stat.isFile())
.catch(() => false))
) {
debug(`not overwriting ${fullPath}\n`);

return;
.catch(() => false);

if (fileExists) {
debug(`route file exists, checking for new handlers: ${fullPath}`);
await this.appendNewHandlers(
fullPath,
contents.replaceAll(
CONTEXT_FILE_TOKEN,
this.findContextPath(destination, path),
),
);

return;
}
}

if (shouldWriteRoutes || shouldWriteTypes) {
Expand Down Expand Up @@ -216,6 +226,147 @@ export class Context {
);
}

/**
* Appends any HTTP-method handler exports that appear in `generatedContent`
* but are absent from the existing file at `fullPath`.
*
* For each new export the corresponding `import type` statement is inserted
* after the last existing import line (or prepended when no imports exist),
* and the export block is appended at the end of the file.
*
* @param fullPath - Absolute path of the route file to update.
* @param generatedContent - The fully-generated file content (used as the
* source of new import and export statements).
*/
private async appendNewHandlers(
fullPath: string,
generatedContent: string,
): Promise<void> {
const existingContent = await fs.readFile(fullPath, "utf8");

// Names already exported by the existing file (e.g. GET, POST).
// RegExp match groups are typed as optional strings, so narrow defensively.
const existingExportNames = new Set<string>(
Array.from(
existingContent.matchAll(/^export\s+const\s+(\w+)/gmu),
(m) => m[1],
).filter((name): name is string => name !== undefined),
);

// All named exports in the generated content together with their type names.
const generatedExports = Array.from(
generatedContent.matchAll(/^export\s+const\s+(\w+)\s*:\s*(\w+)/gmu),
(m) => ({ methodName: m[1], typeName: m[2] }),
).filter(
(
value,
): value is {
methodName: string;
typeName: string;
} => value.methodName !== undefined && value.typeName !== undefined,
);

const newExports = generatedExports.filter(
({ methodName }) => !existingExportNames.has(methodName),
);

if (newExports.length === 0) {
debug(`no new handlers to append to ${fullPath}`);

return;
}

debug(
`appending ${newExports.length} new handler(s) to ${fullPath}: %o`,
newExports.map(({ methodName }) => methodName),
);

const newImportLines: string[] = [];
const newExportBlocks: string[] = [];

for (const { methodName, typeName } of newExports) {
// Both names come from \w+ captures so they are safe identifiers, but
// guard explicitly to satisfy static analysis and avoid RegExp injection.
if (!/^\w+$/u.test(typeName) || !/^\w+$/u.test(methodName)) {
debug(
`skipping handler with unsafe name – methodName: %s, typeName: %s`,
methodName,
typeName,
);
continue;
}

// Find the `import type { TypeName } from "..."` line for this type.
const importMatch = generatedContent.match(
new RegExp(
`^import\\s+type\\s+\\{[^}]*\\b${typeName}\\b[^}]*\\}\\s+from\\s+["'][^"']+["'];`,
"mu",
),
);

if (importMatch?.[0] && !existingContent.includes(importMatch[0])) {
newImportLines.push(importMatch[0]);
}

// Find the export block: from `export const METHOD` to the closing `};`.
// The generated code is always Prettier-formatted, so the closing brace
// and semicolon of every top-level arrow-function export appear on their
// own line as `\n};`.
const startMatch = new RegExp(
`^export\\s+const\\s+${methodName}\\b`,
"mu",
).exec(generatedContent);

if (startMatch) {
const fromExport = generatedContent.slice(startMatch.index);
const closingIndex = fromExport.indexOf("\n};");

if (closingIndex !== -1) {
// Include the closing `};` (3 chars: \n, }, ;)
newExportBlocks.push(fromExport.slice(0, closingIndex + 3));
}
}
}

let updatedContent = existingContent;

// Insert new import lines right after the last existing import statement.
if (newImportLines.length > 0) {
const importMatches = [...existingContent.matchAll(/^import\s[^\n]*/gmu)];

if (importMatches.length > 0) {
const lastImport = importMatches[importMatches.length - 1];

const importIndex = lastImport?.index;
const insertPos =
importIndex === undefined
? 0
: (() => {
const lineEnd = existingContent.indexOf("\n", importIndex);

return lineEnd === -1 ? existingContent.length : lineEnd + 1;
})();

updatedContent =
existingContent.slice(0, insertPos) +
newImportLines.join("\n") +
"\n" +
existingContent.slice(insertPos);
} else {
updatedContent = newImportLines.join("\n") + "\n" + existingContent;
}
}

// Append new export blocks at the end of the file.
if (newExportBlocks.length > 0) {
const separator = updatedContent.endsWith("\n") ? "\n" : "\n\n";
updatedContent += separator + newExportBlocks.join("\n\n") + "\n";
}

await fs.writeFile(fullPath, updatedContent);
debug(`appended new handlers to ${fullPath}`);
}

/**
* Returns the path of the `_.context.ts` file that is nearest to `path` in
* the directory hierarchy, relative to the script's output directory.
Expand Down
145 changes: 145 additions & 0 deletions test/typescript-generator/repository.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "@jest/globals";
import { usingTemporaryFiles } from "using-temporary-files";

import { CodeGenerator } from "../../src/typescript-generator/code-generator.js";
import { Repository } from "../../src/typescript-generator/repository.js";

describe("a Repository", () => {
Expand Down Expand Up @@ -76,4 +77,148 @@ describe("a Repository", () => {
);
});
});

describe("appending new handlers to existing route files", () => {
const specWithGet = {
openapi: "3.0.0" as const,
info: { title: "Test", version: "0.1.0" },
paths: {
"/pet": {
get: {
operationId: "getPets",
responses: { "200": { description: "OK" } },
},
},
},
};

const specWithGetAndPost = {
...specWithGet,
paths: {
"/pet": {
...specWithGet.paths["/pet"],
post: {
operationId: "addPet",
responses: { "200": { description: "OK" } },
},
},
},
};

it("appends a new handler export when the spec gains a new HTTP method", async () => {
await usingTemporaryFiles(async ($) => {
await $.add("openapi.json", JSON.stringify(specWithGet));

// First generation: creates routes/pet.ts with GET only.
await new CodeGenerator($.path("openapi.json"), $.path(""), {
routes: true,
types: true,
}).generate();

const contentAfterFirstGen = await $.read("routes/pet.ts");
expect(contentAfterFirstGen).toContain("export const GET");
expect(contentAfterFirstGen).not.toContain("export const POST");

// Update the spec to add POST, then regenerate.
await $.remove("openapi.json");
await $.add("openapi.json", JSON.stringify(specWithGetAndPost));

await new CodeGenerator($.path("openapi.json"), $.path(""), {
routes: true,
types: true,
}).generate();

const contentAfterSecondGen = await $.read("routes/pet.ts");
expect(contentAfterSecondGen).toContain("export const GET");
expect(contentAfterSecondGen).toContain("export const POST");
});
});

it("preserves user edits to existing handlers when appending a new one", async () => {
await usingTemporaryFiles(async ($) => {
await $.add("openapi.json", JSON.stringify(specWithGet));

await new CodeGenerator($.path("openapi.json"), $.path(""), {
routes: true,
types: true,
}).generate();

// Simulate a user customising the GET handler.
const original = await $.read("routes/pet.ts");
await $.remove("routes/pet.ts");
await $.add(
"routes/pet.ts",
original.replace(
"$.response[200]",
"/* user edit */ $.response[200]",
),
);

// Regenerate with POST added.
await $.remove("openapi.json");
await $.add("openapi.json", JSON.stringify(specWithGetAndPost));

await new CodeGenerator($.path("openapi.json"), $.path(""), {
routes: true,
types: true,
}).generate();

const finalContent = await $.read("routes/pet.ts");
expect(finalContent).toContain("/* user edit */");
expect(finalContent).toContain("export const POST");
});
});

it("prepends missing imports when appending to a route file without existing imports", async () => {
await usingTemporaryFiles(async ($) => {
await $.add("openapi.json", JSON.stringify(specWithGet));

await new CodeGenerator($.path("openapi.json"), $.path(""), {
routes: true,
types: true,
}).generate();

const original = await $.read("routes/pet.ts");
await $.remove("routes/pet.ts");
await $.add(
"routes/pet.ts",
original.replace(/^import\s+type[^\n]*\n/gmu, ""),
);

await $.remove("openapi.json");
await $.add("openapi.json", JSON.stringify(specWithGetAndPost));

await new CodeGenerator($.path("openapi.json"), $.path(""), {
routes: true,
types: true,
}).generate();

const finalContent = await $.read("routes/pet.ts");
expect(finalContent.startsWith("import type { addPet }")).toBe(true);
expect(finalContent).toContain("export const POST");
});
});

it("does not modify an existing route file when no new methods are present", async () => {
await usingTemporaryFiles(async ($) => {
await $.add("openapi.json", JSON.stringify(specWithGetAndPost));

await new CodeGenerator($.path("openapi.json"), $.path(""), {
routes: true,
types: true,
}).generate();

const firstContent = await $.read("routes/pet.ts");

// Regenerate with the same spec — nothing should change.
await new CodeGenerator($.path("openapi.json"), $.path(""), {
routes: true,
types: true,
}).generate();

const secondContent = await $.read("routes/pet.ts");
expect(secondContent).toBe(firstContent);
});
});
});
});
Loading