Skip to content

Commit c8ffe7f

Browse files
committed
More route conventions
1 parent 00f6ff1 commit c8ffe7f

File tree

6 files changed

+625
-4
lines changed

6 files changed

+625
-4
lines changed

README.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,62 @@ Foundatio Mediator is a high-performance mediator library for .NET that uses sou
2929
dotnet add package Foundatio.Mediator
3030
```
3131

32+
Define a message and a handler — no interfaces or base classes required:
33+
34+
```csharp
35+
public record Ping(string Text);
36+
37+
public static class PingHandler
38+
{
39+
public static string Handle(Ping msg) => $"Pong: {msg.Text}";
40+
}
41+
```
42+
43+
Wire up DI and call it:
44+
45+
```csharp
46+
var builder = WebApplication.CreateBuilder(args);
47+
builder.Services.AddMediator();
48+
49+
var app = builder.Build();
50+
51+
app.MapGet("/ping", (IMediator mediator) =>
52+
mediator.Invoke<string>(new Ping("Hello")));
53+
54+
app.Run();
55+
```
56+
57+
That's it. The source generator discovers handlers by naming convention at compile time — zero registration, zero reflection, near-direct-call performance.
58+
59+
### Generate API endpoints
60+
61+
Handlers can automatically become API endpoints. Return `Result<T>` for rich HTTP status mapping:
62+
63+
```csharp
64+
public record CreateTodo(string Title);
65+
public record GetTodo(int Id);
66+
67+
public class TodoHandler
68+
{
69+
public Result<Todo> Handle(CreateTodo command) =>
70+
Result<Todo>.Created(new Todo(1, command.Title));
71+
72+
public Result<Todo> Handle(GetTodo query) =>
73+
query.Id > 0 ? new Todo(query.Id, "Sample") : Result.NotFound();
74+
}
75+
```
76+
77+
```csharp
78+
app.MapMediatorEndpoints();
79+
```
80+
81+
```
82+
POST /api/todos → 201 Created
83+
GET /api/todos/{id} → 200 OK / 404 Not Found
84+
```
85+
86+
Routes, HTTP methods, parameter binding, and OpenAPI metadata are all inferred from your message names and `Result` factory calls.
87+
3288
**👉 [Getting Started Guide](https://mediator.foundatio.dev/guide/getting-started.html)** — step-by-step setup with code samples for ASP.NET Core and console apps.
3389

3490
**📖 [Complete Documentation](https://mediator.foundatio.dev)**

docs/guide/endpoints.md

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ The HTTP method is inferred from the message type name prefix:
240240
| `Delete*`, `Remove*` | DELETE |
241241
| `Patch*` | PATCH |
242242

243-
**Action verbs** — prefixes like `Complete*`, `Approve*`, `Cancel*`, `Submit*`, `Archive*`, `Publish*`, etc., default to **POST** and produce an action route suffix:
243+
**Action verbs** — prefixes like `Complete*`, `Approve*`, `Cancel*`, `Submit*`, `Archive*`, `Publish*`, `Export*`, `Import*`, `Download*`, `Upload*`, etc., default to **POST** and produce an action route suffix:
244244

245245
```csharp
246246
// POST /api/todos/{todoId}/complete
@@ -341,6 +341,61 @@ To override auto-pluralization, use an explicit route:
341341
public Result<Item> Handle(GetItem query) { ... }
342342
```
343343

344+
### Message Naming Conventions
345+
346+
Routes are derived from the **message type name**. The generator strips a verb prefix, normalizes common qualifiers, pluralizes the entity, and converts to kebab-case. Understanding this pipeline helps you write message names that produce clean, consistent routes.
347+
348+
**The golden path** — these naming patterns produce RESTful routes automatically:
349+
350+
```csharp
351+
public record GetTodo(string Id); // → GET /api/todos/{id}
352+
public record GetTodos(); // → GET /api/todos
353+
public record CreateTodo(string Name); // → POST /api/todos
354+
public record UpdateTodo(string Id); // → PUT /api/todos/{id}
355+
public record DeleteTodo(string Id); // → DELETE /api/todos/{id}
356+
public record CompleteTodo(string Id); // → POST /api/todos/{id}/complete
357+
public record ExportTodos(); // → POST /api/todos/export
358+
```
359+
360+
**Common qualifiers are normalized automatically.** The generator handles a variety of CQRS naming conventions, extracting the entity name and producing clean routes:
361+
362+
| Pattern | Example | Route |
363+
| ------- | ------- | ----- |
364+
| **`All` prefix** | `GetAllTodos` | `GET /api/todos` |
365+
| **`ById` suffix** | `GetTodoById` | `GET /api/todos/{id}` |
366+
| **`Details`/`Summary`** | `GetOrderDetails` | `GET /api/orders/{id}` |
367+
| **`Paged`/`Paginated`** | `GetProductsPaged` | `GET /api/products` |
368+
| **`With<Feature>`** | `GetTodoItemsWithPagination` | `GET /api/todo-items` |
369+
| **`By<Property>`** | `GetTodoByName` | `GET /api/todos/by-name` |
370+
| **`For<Entity>`** | `GetOrdersForCustomer` | `GET /api/orders/for-customer/{customerId}` |
371+
| **`From<Entity>`** | `GetOrdersFromUser` | `GET /api/orders/from-user/{userId}` |
372+
| **`Count` suffix** | `GetOrderCount` | `GET /api/orders/count` |
373+
| **Action verbs** | `ExportOrders` | `POST /api/orders/export` |
374+
375+
**Stripped entirely** (query modifiers, not resource identity): `All`, `ById`, `Details`, `Detail`, `Summary`, `List`, `Paged`, `Paginated`, `With<Feature>`
376+
377+
**Converted to route segments** (distinct sub-resources): `By<Property>`, `For<Entity>`, `From<Entity>`, `Count`
378+
379+
**Action prefixes** (produce POST with action suffix): `Complete`, `Approve`, `Cancel`, `Submit`, `Export`, `Import`, `Download`, `Upload`, and [others](./handler-conventions.md)
380+
381+
::: tip
382+
`By<Property>`, `For<Entity>`, and `From<Entity>` produce route segments under the entity, keeping all routes grouped. For example, `GetTodoByName(string Name)` generates `GET /api/todos/by-name?name=...` — no conflict with the list route `GET /api/todos`.
383+
:::
384+
385+
**What to avoid.** Message names that don't reduce to a common entity will produce separate routes. If you see unexpected routes, check your message names:
386+
387+
```csharp
388+
// ❌ These produce different route prefixes — probably not what you want
389+
public record GetTodo(string Id); // → /api/todos/{id}
390+
public record GetActiveTodoCount(); // → /api/active-todo-counts (different prefix!)
391+
392+
// ✅ Better: use consistent entity root, differentiate with parameters
393+
public record GetTodo(string Id); // → /api/todos/{id}
394+
public record GetTodos(bool? Active); // → /api/todos?active=true
395+
```
396+
397+
The generator emits diagnostic **FMED016** when handlers in the same class produce routes with different base paths, alerting you to naming inconsistencies. Use `[HandlerEndpointGroup("Todos")]` to force a shared prefix when message names don't naturally align.
398+
344399
### Route Parameters
345400

346401
Properties named `Id` or ending with `Id` automatically become route parameters:

docs/guide/getting-started.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ This generates:
105105

106106
<!-- -->
107107

108-
HTTP methods, routes, and parameter binding are all inferred from message names and properties. See [Endpoints](./endpoints) for route customization, OpenAPI metadata, authorization, and more.
108+
HTTP methods, routes, and parameter binding are all inferred from message names and properties. Routes derive from the message name, are auto-pluralized, and common qualifiers like `All` and `ById` are normalized. See [Endpoints](./endpoints) for route customization, naming conventions, and more.
109109

110110
## Result Types
111111

src/Foundatio.Mediator/EndpointGenerator.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,48 @@ private static void ValidateEndpoints(SourceProductionContext context, List<Hand
247247
endpoint.Route));
248248
}
249249
}
250+
251+
// FMED016: Handlers in the same class produce routes with different base paths.
252+
// Only check handlers without an explicit [HandlerEndpointGroup] category,
253+
// since categorized handlers already have a shared group prefix.
254+
var uncategorizedByClass = handlers
255+
.Where(h => string.IsNullOrEmpty(h.Endpoint!.Value.CategoryRoutePrefix) && !h.Endpoint!.Value.HasExplicitRoute)
256+
.GroupBy(h => h.Identifier);
257+
258+
foreach (var group in uncategorizedByClass)
259+
{
260+
var routePrefixes = group
261+
.Select(h =>
262+
{
263+
var route = h.Endpoint!.Value.Route.TrimStart('/');
264+
var slashIndex = route.IndexOf('/');
265+
return slashIndex > 0 ? route.Substring(0, slashIndex) : route;
266+
})
267+
.Where(p => !string.IsNullOrEmpty(p))
268+
.Distinct(StringComparer.OrdinalIgnoreCase)
269+
.ToList();
270+
271+
if (routePrefixes.Count > 1)
272+
{
273+
var handlerName = group.Key;
274+
var prefixList = string.Join(", ", routePrefixes.Select(p => "/" + p));
275+
context.ReportDiagnostic(Diagnostic.Create(
276+
new DiagnosticDescriptor(
277+
"FMED016",
278+
"Handler methods generate routes with different base paths",
279+
"Handler methods on '{0}' generate endpoints with different route prefixes ({1}). " +
280+
"This usually means message names don't share a common entity root. " +
281+
"Consider renaming messages to use a consistent noun (e.g., GetTodo instead of GetTodoById), " +
282+
"or use [HandlerEndpointGroup(\"{2}\")] to group them under a shared prefix.",
283+
"Foundatio.Mediator",
284+
DiagnosticSeverity.Warning,
285+
isEnabledByDefault: true),
286+
Location.None,
287+
handlerName,
288+
prefixList,
289+
handlerName.Replace("Handler", "").Replace("Consumer", "") + "s"));
290+
}
291+
}
250292
}
251293

252294
/// <summary>

src/Foundatio.Mediator/HandlerAnalyzer.cs

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -805,7 +805,7 @@ private static string GenerateRoute(
805805
string? actionVerb = null)
806806
{
807807
var parts = new List<string>();
808-
var entityName = RemoveVerbPrefix(messageTypeName);
808+
var (entityName, lookupSuffix) = NormalizeEntityName(RemoveVerbPrefix(messageTypeName));
809809

810810
// If we have a category with a route prefix, the route is relative to that prefix
811811
// Otherwise, we need to include the entity name in the route
@@ -819,6 +819,15 @@ private static string GenerateRoute(
819819
}
820820
}
821821

822+
// Add lookup suffix for By<Property>, For<Entity>, From<Entity>, Count patterns
823+
// e.g., GetTodoByName in "Todos" → /todos/by-name
824+
// GetOrdersForCustomer → /orders/for-customer/{customerId}
825+
// Placed before route params so the suffix reads naturally: /for-customer/{id}
826+
if (lookupSuffix != null)
827+
{
828+
parts.Add(lookupSuffix);
829+
}
830+
822831
// Add route parameters
823832
foreach (var param in routeParams)
824833
{
@@ -874,7 +883,8 @@ private static string GenerateRoute(
874883
"Complete", "Approve", "Cancel", "Submit", "Process",
875884
"Execute", "Activate", "Deactivate", "Archive", "Restore",
876885
"Publish", "Unpublish", "Enable", "Disable", "Reset",
877-
"Confirm", "Reject", "Assign", "Unassign", "Close", "Reopen"
886+
"Confirm", "Reject", "Assign", "Unassign", "Close", "Reopen",
887+
"Export", "Import", "Download", "Upload"
878888
];
879889

880890
/// <summary>
@@ -919,6 +929,86 @@ private static string RemoveVerbPrefix(string name)
919929
return name;
920930
}
921931

932+
/// <summary>
933+
/// Common suffixes stripped from entity names to normalize routes.
934+
/// For example, "TodoById" → "Todo", "OrderDetails" → "Order".
935+
/// Ordered longest-first so "Details" is checked before "Detail".
936+
/// </summary>
937+
private static readonly string[] EntitySuffixes =
938+
[
939+
"Paginated", "Details", "Detail", "Summary", "ById", "Paged", "List"
940+
];
941+
942+
/// <summary>
943+
/// Normalizes an entity name by removing common qualifiers that break route grouping.
944+
/// Returns the normalized entity name and an optional route suffix for lookup patterns.
945+
/// Examples:
946+
/// "AllTodos" → ("Todos", null)
947+
/// "TodoById" → ("Todo", null) — ById stripped, Id becomes route param
948+
/// "TodoByName" → ("Todo", "by-name") — entity extracted, "by-name" becomes route segment
949+
/// "TodoDetails" → ("Todo", null)
950+
/// "TodoItemsWithPagination" → ("TodoItems", null) — With&lt;Feature&gt; stripped
951+
/// "OrderCount" → ("Order", "count") — count becomes route segment
952+
/// "OrdersForCustomer" → ("Orders", "for-customer") — For&lt;Entity&gt; becomes route segment
953+
/// "OrdersFromUser" → ("Orders", "from-user") — From&lt;Entity&gt; becomes route segment
954+
/// </summary>
955+
private static (string entityName, string? routeSuffix) NormalizeEntityName(string entityName)
956+
{
957+
if (string.IsNullOrEmpty(entityName))
958+
return (entityName, null);
959+
960+
// Strip leading "All" prefix (e.g., AllTodos → Todos)
961+
if (entityName.StartsWith("All", StringComparison.Ordinal) && entityName.Length > 3 && char.IsUpper(entityName[3]))
962+
{
963+
entityName = entityName.Substring(3);
964+
}
965+
966+
// Strip known suffixes (e.g., TodoById → Todo, OrderDetails → Order, ProductsPaged → Products)
967+
foreach (var suffix in EntitySuffixes)
968+
{
969+
if (entityName.EndsWith(suffix, StringComparison.Ordinal) && entityName.Length > suffix.Length)
970+
{
971+
entityName = entityName.Substring(0, entityName.Length - suffix.Length);
972+
return (entityName, null);
973+
}
974+
}
975+
976+
// Strip With<Feature> suffix entirely (e.g., TodoItemsWithPagination → TodoItems)
977+
// These are query modifiers (pagination, includes, filters) — not part of the resource identity.
978+
var withIndex = entityName.IndexOf("With", StringComparison.Ordinal);
979+
if (withIndex > 0 && withIndex + 4 < entityName.Length && char.IsUpper(entityName[withIndex + 4]))
980+
{
981+
entityName = entityName.Substring(0, withIndex);
982+
return (entityName, null);
983+
}
984+
985+
// Detect Count suffix (e.g., OrderCount → entity "Order", suffix "count")
986+
// This is a well-established REST convention: GET /api/orders/count
987+
if (entityName.EndsWith("Count", StringComparison.Ordinal) && entityName.Length > 5)
988+
{
989+
entityName = entityName.Substring(0, entityName.Length - 5);
990+
return (entityName, "count");
991+
}
992+
993+
// Detect For<Entity>/From<Entity>/By<Property> patterns — extract entity + route suffix.
994+
// e.g., OrdersForCustomer → ("Orders", "for-customer")
995+
// OrdersFromUser → ("Orders", "from-user")
996+
// TodoByName → ("Todo", "by-name")
997+
// "ById" is already handled above via EntitySuffixes.
998+
foreach (var keyword in new[] { "For", "From", "By" })
999+
{
1000+
var idx = entityName.IndexOf(keyword, StringComparison.Ordinal);
1001+
if (idx > 0 && idx + keyword.Length < entityName.Length && char.IsUpper(entityName[idx + keyword.Length]))
1002+
{
1003+
var suffix = entityName.Substring(idx); // e.g., "ForCustomer"
1004+
entityName = entityName.Substring(0, idx); // e.g., "Orders"
1005+
return (entityName, suffix.ToKebabCase()); // e.g., "for-customer"
1006+
}
1007+
}
1008+
1009+
return (entityName, null);
1010+
}
1011+
9221012
#region Event Detection
9231013

9241014
/// <summary>

0 commit comments

Comments
 (0)