Skip to content
Engineering · 11 min read · Updated

How Elva reads a repo: from route handlers to OpenAPI 3.1

Static analysis, framework fingerprints, and the messy middle where code and spec disagree.

TL;DR
  • Three passes: fingerprint the frameworks, walk every provably reachable route, recover schemas from validators and types.
  • The output is OpenAPI 3.1 with a confidence marker on every inferred field, in about 40 seconds on a 1,204-file repo.
  • Extraction maintains the parts that drift; humans add the intent layer machines cannot see.
Elva TeamThe Elva Team

Elva reads a repo in three passes. Pass one fingerprints the frameworks in play — router registrations, decorators, annotations — and picks the right extractors. Pass two walks every route registration it can prove is reachable and records the method, path template, parameters, and handler. Pass three recovers request and response schemas from validators, type annotations, and serializers, then emits an OpenAPI 3.1 document in which every inferred field carries a confidence marker. On a 1,204-file repository the whole run takes about 40 seconds, and it routinely finds endpoints the team had forgotten were still routable.

Pass one: fingerprint the framework

We do not guess frameworks from filenames or lockfiles. A dependency on express proves nothing: plenty of repos carry it for a single health check while real traffic flows through something else entirely. The fingerprint pass looks for the constructs that actually bind a route to a handler, scores each hit, and only activates an extractor where the evidence clears a threshold.

FrameworkBinding signalWhat the extractor reads
Expressapp.get(), Router(), app.use() mountsHandler functions, middleware chains
FastAPI@app.get, APIRouter(prefix=...)Decorators, Pydantic models, response_model
Spring@RestController, @GetMappingAnnotations, DTO classes, ResponseEntity types
Go (net/http, chi, gin)HandleFunc, r.Get, engine.POSTMux registrations, struct tags
Railsroutes.rb DSLResource routes, controller actions, serializers

Scoring matters most in monorepos. The average repository we index activates 2.3 extractors; the record is nine, in a codebase that had been accumulating services since 2019. A vendored Django admin should never pollute a Go service's catalog, and a scored threshold is what prevents it.

When fingerprints disagree

Frameworks wrap frameworks, and the fingerprints know it. A NestJS app is also an Express app; a Flask blueprint mounted inside a larger WSGI dispatcher looks like two apps sharing one process. Rather than picking one winner, extractors run in a declared precedence order and annotate every endpoint with the layer that actually defined it — a Nest controller is cataloged as Nest, with its decorators and DTOs, while the bare Express health check beside it is cataloged as Express. The payoff shows up in review: when an endpoint looks wrong, the catalog names the extractor and the precedence decision that produced it, so "why does Elva think this route exists" has a specific answer instead of a shrug.

Pass two: walk the routes

Route walking starts at the entrypoints the fingerprint pass identified and follows every registration it can resolve statically. For each one we record five things: HTTP method, path template, path and query parameters, the middleware chain, and the handler symbol itself.

ts
// payments/routes.ts
router.post(
  '/payouts/:id/retry',
  requireKey('payments:write'),
  rateLimit({ rpm: 60 }),
  retryPayout
)

That single registration carries more catalog data than most spec paragraphs. The method and template are explicit. :id becomes a required path parameter. requireKey('payments:write') maps to a security requirement, and rateLimit({ rpm: 60 }) becomes operational metadata that later shows up in the endpoint's MCP tool description. None of it was written for documentation, which is exactly why it does not drift.

Method semantics get checked too. A handler registered under GET that writes to the database gets flagged, because caches, crawlers, and agents are all entitled to assume RFC 9110's method semantics — safe methods stay safe.

Parameters beyond the path

Path parameters are declared in the template, but query and header parameters mostly are not declared anywhere; they are just read. So the extractor follows the handler body. Every req.query.limit, every request.headers.get('Idempotency-Key'), every Pydantic Query(default=50, le=200) becomes a documented parameter, with optionality inferred from guards and defaults recorded where the code states them. Handlers that destructure the request up front are the easy case. Handlers that pass the raw request object through three helper functions are the hard one, and the extractor follows those too, up to a depth limit. In a typical Express service this pass roughly triples the number of documented query parameters compared with what the stale spec admitted to.

Dynamic registration

Not every route is a literal. Plenty of codebases register handlers in loops over config arrays, or mount routers assembled at startup. When the loop's input is a static array in the same module, we unroll it and keep full confidence. When it crosses a module boundary but stays constant, we resolve it and mark the endpoints as derived. When it is genuinely runtime-dependent — feature-flag services, plugin systems — we record the mount point as an unresolved surface instead of pretending it does not exist. Across the repositories we index, 92% of registrations resolve statically, 5% resolve as derived, and the remaining 3% surface as explicit unknowns for a human to annotate.

Middleware is documentation

The middleware chain is the most underrated source of truth in a codebase. Auth middlewares declare security schemes. Validation middlewares carry request schemas. Rate limiters, idempotency guards, and tenant scoping all describe behavior a consumer needs to know and a spec author usually forgets. We resolve chains through app.use() mounts, so an auth guard applied at the /v2 mount point correctly covers all 40 endpoints under it — including the three added last sprint that nobody documented.

Pass three: recover the schemas

Schemas are where "generated from code" earns or loses trust, so evidence is ranked. When multiple sources describe the same payload, the more precise, more intentional source wins, and the disagreement is kept as a review item rather than silently merged.

Validators first

Runtime validators are the gold standard, because they are enforced. A zod schema, a Joi definition, or a Pydantic model is not a claim about the payload; it is the payload's actual gatekeeper. These convert almost losslessly into JSON Schema — enums, bounds, patterns, optionality — and we treat them as authoritative for the request side.

ts
const RetryPayout = z.object({
  reason: z.enum(['timeout', 'bank_error', 'manual']),
  notifyPartner: z.boolean().default(false),
})

Types second

TypeScript interfaces, Go structs, and Java DTOs describe shape but are not enforced at the boundary, so they rank below validators. They are still rich: optional markers, unions, and generics all translate. The main loss is refinement — a string type says nothing about formats or ranges — so type-derived fields carry a lower confidence marker than validator-derived ones.

Serializers and ORM models last

Response shapes often live only in serializers: Rails serializers, Django REST Framework, Prisma selects. Useful, but the distance between "what the model has" and "what the endpoint returns" is where most response-schema drift hides. These fields are marked inferred and become the first candidates for a human confirmation pass.

Tests and traffic as tie-breakers

Two optional evidence sources round out the picture where code alone is ambiguous. Integration tests are specs that cannot lie about what they exercised: a test posting { reason: 'manual' } and asserting a 202 confirms both a valid enum member and a response status the types never mentioned. And for teams that opt in, sampled production traffic settles arguments nothing else can — whether deprecatedAt is ever actually null, whether anyone still sends the legacy pagination parameter. Both sources only ever raise confidence or flag contradictions; neither invents fields. A schema claim that appears in code, is exercised by a test, and matches observed traffic is about as close to ground truth as API documentation gets.

Confidence, spelled out

Every recovered field carries a number, and the scale is deliberately coarse: 0.95 and up for validator-derived facts, around 0.8 for type-derived ones, 0.5 to 0.6 for serializer inference, and below 0.4 for anything reconstructed from usage alone. The catalog renders these as provenance badges, and the review queue sorts ascending, so human attention lands on the least-proven claims first. Confidence also gates exports. A contract can publish with low-confidence fields — humans read contracts and can weigh a caveat — but MCP tool schemas require 0.8 or better on every field they expose, because an agent cannot weigh anything: it either trusts the schema or fails. Teams raise a field's confidence the honest way. Add a validator, and the next sync notices.

When code and spec disagree

About a third of the repos we onboard already have a spec — usually partial, usually stale. The temptation is to pick a winner wholesale. We do not, because each side is authoritative about different things.

The spec records what the API promised. The code records what the API does. Consumers experience the code; reviewers approved the promise. A catalog has to hold both without letting either lie.

The merge policy is short:

  1. Code wins on existence and shape. If the handler no longer exists or a field changed type, the catalog follows the code, and the spec's old claim is recorded as drift.
  2. Spec wins on intent. Descriptions, examples, deprecation notes, and constraints written by humans survive the merge, attached to the code-derived structure.
  3. Conflicts become review items. Anything where both sides are plausible — a looser enum, a nullable the code never exercises — lands in a queue instead of a silent default.

On a mature codebase, the first sync typically surfaces between 12 and 40 disagreements. Teams resolve most of them within the first week, which says less about our tooling and more about how long those questions had been waiting for anyone to ask them.

What lands in the catalog

The output is an OpenAPI 3.1 document per service, plus per-field provenance stored alongside it: which extractor produced the claim, from which file and line, at what confidence.

yaml
/payouts/{id}/retry:
  post:
    operationId: retryPayout
    x-elva-provenance:
      source: payments/routes.ts:41
      schema: validator (zod) · confidence 0.98
    security:
      - apiKey: [payments:write]

Provenance is what makes a generated catalog governable. Every claim traces to a line of code, so review is fast and trust compounds instead of eroding. It also feeds the agent-readiness score: validator-backed schemas rate higher than inferred ones, which is why two endpoints with identical shapes can grade differently.

From catalog to consequence

Nothing downstream works without this layer being honest. Contracts diff against the code-derived shape, so a breaking change is caught even if nobody updated a spec. Generated tests use the recovered schemas as oracles, so a 200 with a malformed body fails instead of passing on status code alone. MCP tool definitions inherit validator-grade schemas and middleware-derived auth, so an agent's view of the API is as accurate as the code itself. The catalog is not a document. It is the substrate every other guarantee stands on.

What 40 seconds actually buys

The number from the opening deserves an itemized receipt. One sync on that 1,204-file repo produced: 214 endpoints cataloged with method, path, parameters, and auth; OpenAPI 3.1 documents for three services that had never had one; 61 request schemas at validator confidence and 143 response schemas at type confidence or better; a drift report against the one stale spec that did exist; and an agent-readiness grade per service — an A, a B, and the D that told the team where the next sprint went. Nobody wrote a line of YAML to get any of it. The catalog exists because the code does, and it stays current for the same reason: the next push re-syncs in about 40 seconds too.

Where it still falls short

Static analysis has edges, and pretending otherwise is how catalogs rot:

  • Heavily metaprogrammed DSLs — Rails method_missing routing tricks resolve partially or not at all
  • Handlers gated behind runtime feature flags register as routable even when a flag keeps them dark
  • GraphQL and gRPC-transcoded surfaces are cataloged as opaque endpoints today, not per-field schemas
  • Generated clients that re-expose upstream APIs can double-count endpoints until an ignore rule says otherwise

Each of these is labeled in the catalog rather than smoothed over. A catalog you can trust is not the one with no gaps; it is the one that tells you exactly where the gaps are.

FAQ

How does Elva find endpoints without a spec?

Three passes over the repo: fingerprint the frameworks, walk every route registration that is provably reachable, and recover schemas from validators and type annotations. The output is OpenAPI 3.1 with a confidence marker on every inferred field.

Which frameworks does the analysis understand?

Express, NestJS, FastAPI, Flask, Spring, Go routers, Rails, and more, with extractors activated per repo by evidence scoring so a vendored framework cannot pollute the catalog.

How long does a repo scan take?

About 40 seconds on a 1,204-file repository, and it routinely finds endpoints the team had forgotten were still routable.

Share: X · LinkedIn ·
READ NEXT

Ship notes, monthly

One email with what shipped and what we learned. Unsubscribe anytime.