# Generate OpenAPI from Express and NestJS code without writing annotations by hand

Node teams usually reach a spec in one of two painful ways: they hand-write OpenAPI and watch it drift from the code, or they decorate every handler and watch the decorators drift from the actual types. Both treat the document as a second thing to maintain. There is a third path: treat the running framework code as the source of truth, prove what it does statically, and reverse-engineer a validated OpenAPI 3.2 document from it. The difference between a scanner that works on real Express and NestJS monorepos and one that produces confident fiction is how it proves routes and types.

## Routes must be traced to the real app instance

Naive extractors pattern-match `app.get(` across files. That approach over-reports immediately: a cache client exposes `client.get(...)`, a feature flag SDK exposes `flags.get(...)`, and neither is an HTTP route. It also under-reports: routes defined on a `Router()` in another file and mounted later are invisible unless the scanner follows the mount.

A deterministic scanner instead traces the `import`/`export` graph to prove that the call target is the actual `express()` application or an `express.Router()` instance. `cache.get(...)` is then correctly ignored, a router that is constructed but never mounted is reported as unreachable rather than emitted, and middleware arrays, chained `Router().use()` composition, and CommonJS `require('express')` modules are traced the same way as ESM. The same tracing covers the patterns real Express apps actually use: mounted sub-routers, `module.exports` controller objects, and `res.render` / `res.redirect` exits.

NestJS is traced through its declarations: `@Controller()` classes, `@Get()` / `@Post()` method decorators, `@Body()` and `@Param()` bindings, and the DTO classes those bindings reference. A Nest monorepo that calls `app.listen()` with no arguments must not crash the scan; the listen call is a server concern, not a route.

## Schemas come from the type checker, not regex

In TypeScript the strongest signal is the compiler itself. The scanner uses the TypeScript checker to resolve the generics that carry real contracts:

``` ts
import type { Request, Response } from "express";

interface CreateUserBody {
  email: string;
  role: "admin" | "member";
  metadata?: Record<string, string>;
}

export async function createUser(
  req: Request<{ orgId: string }, unknown, CreateUserBody, { invite?: string }>,
  res: Response<{ id: string; email: string }>,
) {
  const user = await users.create({
    orgId: req.params.orgId,
    email: req.body.email,
    role: req.body.role,
  });
  res.status(201).json({ id: user.id, email: user.email });
}
```

Resolving `Request<Params, ResBody, ReqBody, Query>` and `Response<User[]>` yields the path params, request body, query parameters, and status-keyed response shape in one pass. Named interfaces, enums, and utility types such as `Partial`, `Pick`, and `Omit` are resolved; Zod schemas are read as the validation contract they already are. Named declarations become reusable `components.schemas` with `$ref`s, while one-off anonymous shapes stay inline. When the TypeScript package is not installed, the pack degrades to syntactic analysis and marks the types it could no longer prove as explicit gaps rather than silently dropping them.

## A conversion function is not a type contract

Query parameters are where guessing is most tempting and most wrong. Seeing `parseInt(req.query.limit, 10)` does **not** prove that `limit` accepts only integers. Consider the actual branches:

``` ts
app.get("/orders", (req, res) => {
  const limit = parseInt(String(req.query.limit ?? "20"), 10);
  const pageSize = Number.isFinite(limit) && limit > 0 && limit <= 100 ? limit : 20;
  res.json(orders.list({ pageSize }));
});
```

A missing parameter falls back to `20`. An empty string, `"abc"`, a negative number, or `99999` all hit the same defaulting branch. The real contract is "an optional query parameter that is coerced and clamped, with invalid input replaced by a default", not "a required integer". The scanner only narrows a parameter's type when the validation or default branch proves the accepted set. When the runtime behavior cannot be proven from the code, the parameter is a `query-unknown` gap instead of an invented `integer`.

## Every contract is proven, proven absent, or a gap

The completeness gate is the property that separates a document you can trust from a bare list of URLs. Each parameter, request body, and response is classified as:

-   **proven**, with evidence from the framework trace and types;
-   **proven absent**, for example when a handler demonstrably takes no body;
-   or an explicit **gap** with a code such as `query-unknown`, `body-schema-unknown`, `response-unknown`, or `auth-unknown`.

Each operation then carries a confidence level: `high` when framework trace plus types and literals prove the contract, `medium` when the route and shape are proven but some schema detail is inferred, and `low` when only syntactic evidence exists. A route is never emitted as a URL with empty contracts, and dynamic route expressions or orphan routers land in an `unresolved` list instead of being guessed.

## AI fills only the gaps, visibly

The scanning package itself never calls a model vendor. It ships the prompt contract and a strict response validator; the host application makes any model call, behind an explicit opt-in, using the user's own model configuration. When enabled, the model receives only the small handler slice for routes that actually have gaps, never whole files, and its answer is clamped to a safe JSON Schema subset with no `$ref`s and bounded depth and property counts.

Two hard rules keep this honest. The resolver can fill query parameters, headers, request bodies, status-keyed response schemas, and SSE event payloads, but it can **never invent a route, method, or path**. And a failed fill is never fatal: returning `null` leaves the gap visible in the report. The desktop surface shows each proposed fill for review, where it can be accepted, edited, or rejected, so the model is an assistant to a human decision rather than an invisible source of schema.

## Rescans preserve the work you already did

A `.powerduck/discovery.json` sidecar fingerprints files and routes; it is the only place scan provenance is stored, so the generated document stays clean and editable. A rescan diffs added, changed, and removed routes, and a three-way merge applies the result to your current spec with manual edits always winning:

-   Added routes are inserted; unchanged routes are left exactly as you wrote them.
-   Changed routes refresh structural contracts while preserving descriptions, tags, examples, `operationId`, deprecation flags, and every `x-` extension.
-   Removed routes are flagged for review, never deleted silently.
-   `components.schemas` and `securitySchemes` are add-only, with collisions renamed and their refs rewritten.

That is what makes scanning safe to run repeatedly in CI rather than a one-time import that overwrites human work.

## Running it

``` ts
import { scanProject } from "@powerduck/code-to-openapi";

const result = await scanProject({
  root: "./api",
  frameworks: ["express"], // or "nest"
});

console.log(
  `${result.report.routesConfirmed} confirmed, ` +
  `${result.report.routesPartial} partial`,
);

const { document, documentValid } = await result.convert();
console.log("OpenAPI 3.2 valid:", documentValid);
```

TypeScript and JavaScript are analyzed with the compiler checker; other languages use tree-sitter parsers shipped as WASM, so no per-language toolchain has to be installed. Point it at an Express or NestJS project and the output is a validated document with honest gaps rather than a polished guess. You can try the same local-first, spec-driven workflow, including the reviewable AI gap fills, in the [online demo](https://www.powerduck.com/app/?ref=powerduck.com), and compare it with the cross-language approach in the broader [code-to-OpenAPI overview](https://www.powerduck.com/blog/generate-openapi-from-existing-code/).

