diff --git a/CHANGELOG.md b/CHANGELOG.md index 57be2d1a..178cfd1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/__tests__/fastendpoints-routes.test.ts b/__tests__/fastendpoints-routes.test.ts new file mode 100644 index 00000000..43ba746c --- /dev/null +++ b/__tests__/fastendpoints-routes.test.ts @@ -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 = { + '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 +{ + 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 +{ + 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 +{ + 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']); + }); +}); diff --git a/src/resolution/frameworks/csharp.ts b/src/resolution/frameworks/csharp.ts index 765acbd6..51040c20 100644 --- a/src/resolution/frameworks/csharp.ts +++ b/src/resolution/frameworks/csharp.ts @@ -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`, + // `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 {