All posts

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.

  1. Parse

    Read the code

    Load the project into a TypeScript syntax tree with ts-morph.

  2. Find

    Locate fetch calls

    Walk every call expression; keep fetch, window.fetch and globalThis.fetch.

  3. Resolve

    Build real URLs

    Follow variables and string concatenation; fill environment values from .env.

  4. Snapshot

    Record responses

    Call each endpoint and save its JSON to .apiRestContracts.json.

  5. Compare

    Report drift

    Later, call again and deep-diff against the snapshot.

scanSalmon runs the first four steps; swimSalmon runs the last

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.