DEV Community

Cover image for Mapping every JavaScript call to its ASP.NET endpoint - even in a VB.NET WebForms app
SeniorDev
SeniorDev

Posted on

Mapping every JavaScript call to its ASP.NET endpoint - even in a VB.NET WebForms app

Find All References stops where C# ends and JavaScript begins. Here's how I built a Visual Studio extension that links the two - and what legacy code taught me about matching URLs to routes.

"If I change this endpoint, what breaks?"

On a small ASP.NET Core app you can answer that from memory. On a big one you can't, and Visual Studio won't help you: Find All References stops at the language boundary. It knows every C# call to a controller method, but it has no idea that fetch('/api/orders/checkout') in a JavaScript file, an $.ajax call inside an .aspx page, or an asp-action in a Razor view reaches that same method.

I hit this hardest on a legacy application: VB.NET WebForms plus Web API 2, around 780 .vb files, with JavaScript split between a Scripts folder and <script> blocks inside .aspx and .ascx pages. Renaming a route there meant searching for URL fragments and hoping.

So I built FullStackCodeLens, a Visual Studio extension that puts a line above your code showing who calls it and where it calls out to - across C#, VB.NET, JavaScript/TypeScript, Razor, Blazor and WebForms. This post is about how it works and the surprisingly tricky parts of matching a URL in JavaScript to the route that serves it.

What it looks like

Take a plain API controller:

[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
    [HttpGet("{id}")]
    public IActionResult GetById(int id) { ... }

    [HttpGet("search")]
    public IActionResult Search(string term) { ... }

    [HttpPut("{id}")]
    public IActionResult Update(int id, ProductDto dto) => NoContent();
}
Enter fullscreen mode Exit fullscreen mode

and the JavaScript that calls it, written the way real code bases mix styles:

function loadProduct(id) {
    return $.get('/api/products/' + id);
}

function searchProducts(term) {
    return axios.get(`/api/products/search?term=${encodeURIComponent(term)}`);
}

function saveProduct(product) {
    return $.ajax({
        url: '/api/products/' + product.id,
        method: 'PUT',
        data: JSON.stringify({ name: product.name, price: product.price })
    });
}
Enter fullscreen mode Exit fullscreen mode

Above GetById you now see which JavaScript calls reach it, and above loadProduct you see the action it calls - each name clickable:

When there are many callers, the line expands into a list with file and line number, and a "+N more" link instead of running off the screen:

Why text scanning and not Roslyn

The obvious tool for code analysis in .NET is Roslyn. I chose regular-expression scanning of the source text instead, for three reasons:

  1. Half of the problem isn't C#. JavaScript, TypeScript, .cshtml, .razor, .aspx and .ascx files all need reading, and Roslyn doesn't understand any of them.
  2. Legacy projects often don't build cleanly on a developer's machine, or are VB.NET projects with old project formats. A scanner that only needs the files works anyway.
  3. Speed. A full scan of that 780-file application takes about 1-1.5 seconds, in parallel on a background thread.

The trade-off is that a scanner only sees what code looks like. Most of the work went into the cases where looks are misleading.

Hard part 1: building the URL from JavaScript

A URL in JavaScript is rarely a single string literal. The scanner has to turn all of these into a pattern it can match:

fetch(`/api/orders/${orderId}/cancel`, { method: 'POST' });   // template literal
$.getJSON("/api/invoices/" + id, done);                       // concatenation
var url = Host + "/api/customers/" + id;                      // via a variable
$.ajax({ url: url, type: "DELETE" });
Enter fullscreen mode Exit fullscreen mode

Template literals and concatenated expressions become wildcards, so /api/invoices/" + id becomes /api/invoices/{*}. Plain variables are resolved from their assignments in the same file, and prefixes like location.protocol + "//" + location.host are dropped. Kendo data sources (read: / update: / destroy:) and url: function () { return ... } are read too.

The HTTP verb matters as much as the URL, and it isn't always on the same line. One bug I found while writing the demo for this article:

function checkout(cartId, email) {
    return fetch('/api/orders/checkout', {
        method: 'POST',
        body: JSON.stringify({ cartId: cartId, email: email, giftWrap: true })
    });
}
Enter fullscreen mode Exit fullscreen mode

The scanner read only to the end of the fetch( line, found no method, and assumed GET - so this POST matched no route. It now reads the whole argument list up to the matching parenthesis.

Hard part 2: one URL, several matching routes

/api/products/search?term=lamp textually matches both api/products/search and api/products/{id} - search is a perfectly good value for {id}. Counting the call for both actions is wrong: ASP.NET prefers the literal segment, so GetById would show a caller it never gets.

So each call is owned only by its best route(s), using the same rules ASP.NET does:

  • an attribute route wins over a conventional route;
  • a literal segment beats a parameter: a known word in the URL facing {id} costs a little, while a computed value (${x}) facing a literal search costs a lot, because it's only a guess;
  • Web API 2 overloads that differ only by query parameters - Get(id) versus Get(id, bankId) - are told apart by which query keys the call actually sends.

Checking this against the legacy application, 37 calls lost a wrong second owner (/companies/users no longer counted against GetCompanyInfo({id})), and nothing else changed.

Hard part 3: routes that aren't written on the action

Web API 2 apps often have no route attributes at all. The routes live in one place:

config.Routes.MapHttpRoute(
    name:="DefaultApi",
    routeTemplate:="api/{controller}/{id}",
    defaults:=New With {.id = RouteParameter.Optional}
)
Enter fullscreen mode Exit fullscreen mode

To know that InvoicesController.GetInvoice(id) serves GET /api/invoices/5, the extension reads every MapHttpRoute in the solution, fills in {controller}, keeps the optional {id} only when the action actually has that parameter, and takes the verb from the action's name prefix (Get..., Post..., Delete...), as Web API does.

VB.NET itself had a surprise: a method containing an XML literal with SQL inside, like (<string>SELECT ... </string>), left a parenthesis "open" for the line-joining logic and swallowed the methods after it. The reader now joins continued lines only for declarations and attributes, never for statement code.

On that application, the first C#-only version found 0 routes. The current one finds 387, and matches 143 of the 145 AJAX calls in the main script. The two left over target URLs no server route defines - real dead code, which is useful to know too.

Hard part 4: JavaScript calling JavaScript

Linking showAlert(...) in one script to function showAlert in another sounds easy until you run it on a real solution. Rules I had to add after seeing the false positives:

  • A file's own parameters and variables are never cross-file calls. callback(response) matched a function callback in a bootstrap file 60 times.
  • Names shorter than 3 characters are ignored - minified code is full of e( and t(.
  • Bundles aren't targets. A bundle.js that contains 80% of the functions of several smaller scripts would otherwise "own" every call.
  • Libraries link only to themselves. jQuery's internal helpers matched function names in Modernizr.

Object members (var Invoices_Ajax = { load: function ... called as Invoices_Ajax.load(...)), jQuery plugins (this.refresh = function inside $.fn.invoicePanel), onclick="..." attributes in pages, and script sent from code-behind (ScriptManager.RegisterStartupScript(...)) are all covered.

Where Visual Studio's CodeLens can't go

The first versions used Visual Studio's own CodeLens API. It doesn't reach far enough: CodeLens only decorates declarations, so it can't sit above app.MapPost(...) in Program.cs or above an anonymous callback. And on some project types - classic WebForms web projects in particular - Visual Studio shows no CodeLens in JavaScript files at all.

So the labels are drawn by the extension itself, using the same editor mechanism CodeLens uses (inter-line adornments). That also made it possible to put them in .cshtml, .razor, .aspx and .ascx files:

The tool windows

Besides the in-editor lines there are four tool windows. The one I use most is Route Explorer: every route in the solution with its callers, flagging conflicts (two actions that answer the same URL), routes nobody calls, routes without [Authorize], and DTO drift - like the giftWrap property in the checkout() example above, which the server's CheckoutRequest doesn't have. It can also export the routes as a .http file or an OpenAPI document.

Try it

FullStackCodeLens is on the Visual Studio Marketplace:

Full disclosure: it's my own extension and it's paid - a 30-day trial with everything enabled, then a $12 one-time license, no subscription.

If you try it on a large or old code base, I'd really like to hear which call patterns it misses. Every rule in this post came from a real project that broke an earlier version.

Top comments (0)