fix(aspnet): FastEndpoints routes, and minimal API paths get a leading slash (#2158)

- FastEndpoints: an endpoint class (`: Endpoint<…>`, `EndpointWithoutRequest`,
  `Ep.…`) declares `Get(…)` / `Post(…)` in `Configure()`; each becomes a route
  linked to the class's own HandleAsync / ExecuteAsync (the bare-member scope
  from #2151 keeps two `List` endpoints apart). A constant path
  (`CreateContributorRequest.Route`) is read in postExtract from wherever the
  class declares it; `$"/{nameof(Project)}s"` reads as `/Projects`.
- `app.MapGet("api/todos", …)` is named `/api/todos`.

A/B: ardalis/CleanArchitecture 0 → 24 routes (+24 edges); eShopOnWeb and
eShop only re-key routes whose path gained a leading slash; realworld and
jasontaylordev/CleanArchitecture byte-identical.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 11:30:56 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 3151d5c469
commit 9269f92dd5
3 changed files with 211 additions and 31 deletions
+1
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- ASP.NET apps built with FastEndpoints now have their routes: each endpoint class's `Get(…)` / `Post(…)` in `Configure()` becomes a route linked to its own `HandleAsync` / `ExecuteAsync`, including paths kept in a request class's `Route` constant. ardalis/CleanArchitecture went from no routes to 24. Minimal API routes written without a leading slash (`app.MapGet("api/todos", …)`) are now named `/api/todos`.
- Vapor routes whose handler is a trailing closure (`app.get("hello") { req in … }`), WebSocket routes (`app.webSocket("chat") { … }`) and `routes.on(.POST, "x", use: handler)` registrations are now found. Only routes with a `use:` handler were read before: the Swift Package Index server went from 13 routes to 34, and a WebSocket-only chat server from none to one.
- Play projects kept in subdirectories are recognized, so each one's `conf/routes` is read. A repository with no Play build at its root, like playframework's samples, went from no routes to over 130.
- Rails routes are now read with their nesting: `namespace` and `scope` add their path and controller module, nested `resources` sit under their parent's `:id`, and `member` / `collection` blocks add their actions. A namespaced route now links to its own module's controller. Routes in a Rails engine's `config/routes.rb` are found too: solidus went from no routes to over 600, and mastodon's route-to-action links nearly tripled.
+104
View File
@@ -0,0 +1,104 @@
/**
* FastEndpoints routes: an endpoint class declares its verb and path in
* `Configure()` — `Get("/Contributors")`, `Post(CreateContributorRequest.Route)`
* with the constant in the request's own file — and handles the request in
* its own `HandleAsync` / `ExecuteAsync`. ardalis/CleanArchitecture had no
* routes at all. A minimal API's `app.MapGet("api/todos", …)` serves
* `/api/todos`.
*/
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fastendpoints-'));
const files: Record<string, string> = {
'src/Web/Contributors/Create.CreateContributorRequest.cs': `namespace Web.Contributors;
public class CreateContributorRequest
{
public const string Route = "/Contributors";
public string Name { get; set; } = "";
}
`,
'src/Web/Contributors/Create.cs': `namespace Web.Contributors;
public class Create : Endpoint<CreateContributorRequest, CreateContributorResponse>
{
public override void Configure()
{
Post(CreateContributorRequest.Route);
AllowAnonymous();
}
public override async Task ExecuteAsync(CreateContributorRequest request, CancellationToken ct)
{
}
}
`,
'src/Web/Contributors/List.cs': `namespace Web.Contributors;
public class List : EndpointWithoutRequest<ContributorListResponse>
{
public override void Configure()
{
Get("/Contributors");
AllowAnonymous();
}
public override async Task HandleAsync(CancellationToken ct)
{
}
}
`,
'src/Web/Projects/List.cs': `namespace Web.Projects;
public class List : EndpointWithoutRequest<ProjectListResponse>
{
public override void Configure()
{
Get($"/{nameof(Project)}s");
}
public override async Task HandleAsync(CancellationToken ct)
{
}
}
`,
'src/Api/Program.cs': `var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("api/todos", GetTodos);
`,
};
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
cg = await CodeGraph.init(root, { index: true });
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
const handlerOf = (file: string): string[] => {
const route = cg.getNodesByKind('route').find((r) => r.filePath === file)!;
return cg.getOutgoingEdges(route.id).map((e) => cg.getNode(e.target)!.qualifiedName);
};
describe('FastEndpoints and minimal API routes', () => {
it('names each endpoint by its path, constant paths included, and links its own handler', () => {
const names = cg.getNodesByKind('route').map((r) => r.name).sort();
expect(names).toEqual(['GET /Contributors', 'GET /Projects', 'GET /api/todos', 'POST /Contributors']);
expect(handlerOf('src/Web/Contributors/Create.cs')).toEqual(['Web.Contributors::Create::ExecuteAsync']);
// Two classes named `List`: each route reaches its own class's handler.
expect(handlerOf('src/Web/Contributors/List.cs')).toEqual(['Web.Contributors::List::HandleAsync']);
expect(handlerOf('src/Web/Projects/List.cs')).toEqual(['Web.Projects::List::HandleAsync']);
});
});
+106 -31
View File
@@ -191,7 +191,9 @@ export const aspnetResolver: FrameworkResolver = {
// Minimal APIs: app.MapGet("/path", handler)
const minimalRegex = /\.Map(Get|Post|Put|Patch|Delete)\s*\(\s*"([^"]+)"\s*,\s*([^,)]+)/g;
while ((match = minimalRegex.exec(safe)) !== null) {
const [, verb, routePath, handlerExpr] = match;
const [, verb, rawPath, handlerExpr] = match;
// `app.MapGet("api/todos", …)` serves `/api/todos`.
const routePath = joinCsPath('', rawPath!);
const method = verb!.toUpperCase();
const line = safe.slice(0, match.index).split('\n').length;
@@ -272,45 +274,118 @@ export const aspnetResolver: FrameworkResolver = {
});
}
// FastEndpoints: an endpoint class (`: Endpoint<TReq, TRes>`,
// `EndpointWithoutRequest`, `Ep.Req<…>.Res<…>`) declares its verb and path in
// `Configure()` — `Get("/Contributors")`, `Post(CreateContributorRequest.Route)`
// — and handles the request in its own `HandleAsync` / `ExecuteAsync`. A
// constant path (`X.Route`, usually in the request's own file) is read in
// postExtract; until then the route is named by the expression.
const endpointClass = /\bclass\s+([A-Za-z_]\w*)[^{;]*?:\s*(?:FastEndpoints\.)?(?:Endpoint(?:WithoutRequest|WithoutResponse)?\b|Ep\.)/g;
while ((match = endpointClass.exec(safe)) !== null) {
const body = safe.slice(match.index);
const configure = /\bvoid\s+Configure\s*\(\s*\)\s*\{/.exec(body);
if (!configure) continue;
const handler = /\b(HandleAsync|ExecuteAsync)\s*\(/.exec(body)?.[1];
const verbRegex = /\b(Get|Post|Put|Patch|Delete)\s*\(\s*([^;]*?)\s*\)\s*;/g;
verbRegex.lastIndex = configure.index;
const configureEnd = configure.index + 3000;
let verb: RegExpExecArray | null;
while ((verb = verbRegex.exec(body)) !== null && verb.index < configureEnd) {
const method = verb[1]!.toUpperCase();
const args = verb[2]!;
// `$"/{nameof(Project)}s"` is `/Projects`.
const literals = [...args.matchAll(/"([^"]+)"/g)].map((l) => joinCsPath('', l[1]!.replace(/\{\s*nameof\s*\(\s*(\w+)\s*\)\s*\}/g, '$1')));
const constant = /^([A-Za-z_]\w*)\.([A-Za-z_]\w*)$/.exec(args);
const targets = literals.length > 0 ? literals.map((p) => ({ name: p, key: p })) : constant ? [{ name: `${constant[1]}.${constant[2]}`, key: `const:${constant[1]}.${constant[2]}` }] : [];
const line = safe.slice(0, match.index + verb.index).split('\n').length;
for (const t of targets) {
const id = `route:${filePath}:${line}:${method}:${t.name}`;
nodes.push({
id, kind: 'route', name: `${method} ${t.name}`,
qualifiedName: `${filePath}::fastendpoint:${method}:${t.key}`,
filePath, startLine: line, endLine: line, startColumn: 0, endColumn: 0, language: 'csharp', updatedAt: now,
});
if (handler) references.push({ fromNodeId: id, referenceName: handler, referenceKind: 'references', line, column: 0, filePath, language: 'csharp' });
}
// A second verb call (`Get(...); Post(...)`) is a second route; stop at Configure's end.
if (/\n\s*\}\s*\n/.test(body.slice(configure.index, verb.index))) break;
}
}
return { nodes, references };
},
/**
* The endpoint-group prefix convention, read once from the app: the
* `MapGroup($"/api/{groupName}")` that registers every `IEndpointGroup`
* (or `EndpointGroupBase`) under a head — `/api/` — before the class name.
* A group route extracted as `POST /TodoItems` becomes `POST /api/TodoItems`;
* a class with its own `RoutePrefix` literal already has its path. Idempotent:
* `qualifiedName` keeps the group and the sub-path.
*/
/** Route names only the whole repository can give: FastEndpoints constant paths, endpoint-group prefixes. */
postExtract(context: ResolutionContext): Node[] {
let head: string | null = null;
let looked = 0;
for (const file of context.getAllFiles()) {
if (!file.endsWith('.cs')) continue;
const content = context.readFile(file);
if (!content || !content.includes('MapGroup')) continue;
if (++looked > 400) break;
const m = /\$"([^"{]*)\{\s*(?:groupName|type\.Name|name|prefix)\s*\}"/.exec(content) ?? /MapGroup\(\s*\$"([^"{]*)\{/.exec(content);
return [...fastEndpointConstantRoutes(context), ...endpointGroupRoutes(context)];
},
};
/**
* A FastEndpoints route named by a constant (`Post(CreateContributorRequest.Route)`)
* gets the constant's value: `public const string Route = "/Contributors";` in
* that class, wherever it is declared. Idempotent — the qualified name keeps the
* expression.
*/
function fastEndpointConstantRoutes(context: ResolutionContext): Node[] {
const updates: Node[] = [];
for (const route of context.getNodesByKind('route')) {
if (route.language !== 'csharp') continue;
const q = /::fastendpoint:([A-Z]+):const:([A-Za-z_]\w*)\.([A-Za-z_]\w*)$/.exec(route.qualifiedName);
if (!q) continue;
const [, method, owner, field] = q;
let value: string | null = null;
for (const decl of context.getNodesByName(owner!)) {
if (decl.language !== 'csharp' || (decl.kind !== 'class' && decl.kind !== 'struct')) continue;
const text = context.readFile(decl.filePath) ?? '';
const m = new RegExp(`\\b(?:const\\s+string|static\\s+readonly\\s+string|static\\s+string)\\s+${field}\\s*=\\s*"([^"]+)"`).exec(text);
if (m) {
head = m[1]!;
value = m[1]!;
break;
}
}
if (!head || head === '/' || head === '') return [];
const updates: Node[] = [];
for (const route of context.getNodesByKind('route')) {
if (route.language !== 'csharp') continue;
const q = /::group:([A-Za-z_]\w*):([A-Z]+):(.*)$/.exec(route.qualifiedName);
if (!q) continue;
const content = context.readFile(route.filePath);
if (content && /\bRoutePrefix\s*(?:=>|=)\s*"/.test(content)) continue;
const name = `${q[2]} ${joinCsPath(head.replace(/\/+$/, '') + '/' + q[1], q[3]!)}`;
if (name !== route.name) updates.push({ ...route, name });
if (!value) continue;
const name = `${method} ${joinCsPath('', value)}`;
if (name !== route.name) updates.push({ ...route, name });
}
return updates;
}
/**
* The endpoint-group prefix convention, read once from the app: the
* `MapGroup($"/api/{groupName}")` that registers every `IEndpointGroup`
* (or `EndpointGroupBase`) under a head — `/api/` — before the class name.
* A group route extracted as `POST /TodoItems` becomes `POST /api/TodoItems`;
* a class with its own `RoutePrefix` literal already has its path. Idempotent:
* `qualifiedName` keeps the group and the sub-path.
*/
function endpointGroupRoutes(context: ResolutionContext): Node[] {
let head: string | null = null;
let looked = 0;
for (const file of context.getAllFiles()) {
if (!file.endsWith('.cs')) continue;
const content = context.readFile(file);
if (!content || !content.includes('MapGroup')) continue;
if (++looked > 400) break;
const m = /\$"([^"{]*)\{\s*(?:groupName|type\.Name|name|prefix)\s*\}"/.exec(content) ?? /MapGroup\(\s*\$"([^"{]*)\{/.exec(content);
if (m) {
head = m[1]!;
break;
}
return updates;
},
};
}
if (!head || head === '/' || head === '') return [];
const updates: Node[] = [];
for (const route of context.getNodesByKind('route')) {
if (route.language !== 'csharp') continue;
const q = /::group:([A-Za-z_]\w*):([A-Z]+):(.*)$/.exec(route.qualifiedName);
if (!q) continue;
const content = context.readFile(route.filePath);
if (content && /\bRoutePrefix\s*(?:=>|=)\s*"/.test(content)) continue;
const name = `${q[2]} ${joinCsPath(head.replace(/\/+$/, '') + '/' + q[1], q[3]!)}`;
if (name !== route.name) updates.push({ ...route, name });
}
return updates;
}
/** Join a class-level [Route] prefix and an action's path into one normalized `/path`. */
function joinCsPath(prefix: string, sub: string): string {