Code Salmon: catching API drift without a spec
An open-source CLI that finds the REST calls in a TypeScript codebase by reading its syntax tree, snapshots what those APIs return, and tells you when the answers change.
Most frontends depend on APIs they don’t control. When one of those APIs quietly renames a field or
drops one, nothing fails at build time; the app just starts showing undefined somewhere. Tools
that catch this usually need a formal contract first, such as an OpenAPI spec. Many real projects
don’t have one.
Code Salmon is an open-source command-line tool I contributed to that works without one. It reads
the code to find the API calls, records what those APIs return today, and compares against that
record later. Two commands: scanSalmon takes the snapshot, swimSalmon checks for drift.
Parse
Read the code
Load the project into a TypeScript syntax tree with ts-morph.
Find
Locate fetch calls
Walk every call expression; keep fetch, window.fetch and globalThis.fetch.
Resolve
Build real URLs
Follow variables and string concatenation; fill environment values from .env.
Snapshot
Record responses
Call each endpoint and save its JSON to .apiRestContracts.json.
Compare
Report drift
Later, call again and deep-diff against the snapshot.
Reading code as a tree, not as text
The obvious first attempt is a regular expression, something like
fetch\s*\(\s*['"]([^'"]+)['"]. It works until the URL isn’t a plain string, which in real code is
most of the time:
const BASE = "https://api.example.com/v1/users?key=" + process.env.API_KEY;
const res = await fetch(BASE);
Here the call’s argument is just the name BASE. To know where it points, you have to find where
BASE was declared and read its value, and that value is itself an expression. That’s a job for
the compiler’s view of the code, not for pattern matching.
ts-morph wraps the TypeScript compiler API. If the project has a
tsconfig.json, Code Salmon loads it exactly as the compiler would; otherwise it adds every .ts
file outside node_modules. Then it asks each file for its call expressions and keeps the fetches:
const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
for (const c of calls) {
const expr = c.getExpression();
const isFetchCall =
expr.getText() === "fetch" ||
expr.getText() === "window.fetch" ||
expr.getText() === "globalThis.fetch";
if (!isFetchCall) continue;
// ...inspect the first argument
}
The first argument is then handled by its kind in the tree. A StringLiteral is the URL as
written. A BinaryExpression is a concatenation, so the left side is the base and the right side
is usually an environment variable. An Identifier is a variable name, which the type checker can
follow back to its declaration:
const symbol = urlArg.getSymbol();
const decl = symbol?.getDeclarations()?.[0];
if (decl?.getKind() === SyntaxKind.VariableDeclaration) {
const initializer = (decl as VariableDeclaration).getInitializer();
// a string literal, or another concatenation to take apart
}
This is the same symbol resolution an editor uses for “go to definition”, which is why it holds up where a regex can’t.
The hard part: secrets the tool can’t see
Most real API URLs contain a key, and keys live in environment variables, not in the code. The
syntax tree tells you which variable a URL needs (process.env.API_KEY), but not its value.
Getting this right was the last real obstacle. The tool loads the scanned project’s own .env file
from wherever it’s run, and when a variable is referenced but missing, it asks for it instead of
failing or silently calling a broken URL:
async function resolveEnvVar(name: string): Promise<string | undefined> {
const existing = process.env[name];
if (existing) return existing;
const answers = await inquirer.prompt([
{
type: "confirm",
name: "allowInput",
message: `Would you like to enter a value for ${name}?`,
},
{
type: "input",
name: "userValue",
message: `Enter value for ${name}:`,
when: (a) => a.allowInput,
validate: (input) => input.trim() !== "" || "Value cannot be empty",
},
]);
// ...store it for the rest of the run
}
The value is only held in memory for that run. The scan also looks at request headers for
properties named like auth or key that read from process.env, so keys passed in headers are
recognised too.
Snapshot, then compare
With real URLs in hand, scanSalmon calls each one and stores the JSON it gets back. Failures are
recorded rather than thrown, so one dead endpoint doesn’t stop the scan: a non-2xx status becomes
{ error: "HTTP 404" }, a non-JSON response becomes { error: "Non-JSON response" }. The snapshot
is written to .apiRestContracts.json at the root of the git repository (found with
git rev-parse --show-toplevel), so it can be committed and compared over time.
swimSalmon reads that file, calls each endpoint again, and hands both versions to
deep-diff, which returns a list of changes, each with a
path into the JSON and a kind:
const drift = diff(initial, current);
if (!drift) return displayMessage("No drift detected!", greens);
drift.forEach((change) => {
const path = change.path?.join(".") || "(root)";
switch (change.kind) {
case "E": // edited: a value changed
case "N": // new: a field appeared
case "D": // deleted: a field disappeared
}
});
A field the frontend reads that has been deleted shows up as Removed at: user.profile.avatar, the
exact path to look for in the code.
What I’d build next
Shipping it showed where the design could go further:
- Compare shapes, not just values. Diffing raw values flags a changed timestamp the same way as a removed field. Reducing each response to its structure (field names and types) before diffing would separate real contract changes from ordinary data.
- Exit codes for CI. Returning a non-zero code on drift would let a pipeline fail a build, which is where a check like this is most useful.
- More call styles. Following clients like axios, and template-literal URLs, would cover more real code.
The site
Alongside the tool, I worked on its landing and documentation site: a Next.js app exported to static HTML, with a “detective” theme (a film-grain hero, and documentation set on ruled legal-pad paper) so the docs were something people would actually read.