mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-02 09:45:39 +08:00
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:
co-authored by
Claude Opus 5.5
parent
3151d5c469
commit
9269f92dd5
@@ -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.
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user