Skip to content

Commit 57fabf5

Browse files
committed
Auto-pluralize routes
1 parent c247dbb commit 57fabf5

File tree

7 files changed

+276
-18
lines changed

7 files changed

+276
-18
lines changed

docs/guide/configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ The following properties on `MediatorConfigurationAttribute` control endpoint ge
177177
**`EndpointRoutePrefix`** (`string?`)
178178

179179
- **Default:** `"api"`
180-
- **Effect:** Sets a global route prefix that all category groups nest under. Categories auto-derive their route from their name (e.g., `[HandlerEndpointGroup("Products")]``products`), composing with the global prefix to produce `/api/products`.
180+
- **Effect:** Sets a global route prefix that all category groups nest under. Categories auto-derive their route from their name (e.g., `[HandlerEndpointGroup("Products")]``products`), composing with the global prefix to produce `/api/products`. Convention-based entity routes are auto-pluralized (e.g., `GetProduct``/products/{productId}`).
181181
- **Important:** Category-level `RoutePrefix` values without a leading `/` are **relative** to this global prefix. Don't include `api` in your category prefixes when using the default global prefix, or you'll get `/api/api/...`. Use a leading `/` on a category prefix to make it absolute (bypasses the global prefix).
182182
- **To disable:** Set `EndpointRoutePrefix = ""` to remove the global prefix entirely, then use full paths in category prefixes.
183183

docs/guide/endpoints.md

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,17 +30,17 @@ app.Run();
3030
That's it. You now have:
3131

3232
```text
33-
POST /api/product → CreateProduct
34-
GET /api/product/{productId} → GetProduct
35-
GET /api/product → GetProducts
36-
PUT /api/product/{productId} → UpdateProduct
37-
DELETE /api/product/{productId} → DeleteProduct
33+
POST /api/products → CreateProduct
34+
GET /api/products/{productId} → GetProduct
35+
GET /api/products → GetProducts
36+
PUT /api/products/{productId} → UpdateProduct
37+
DELETE /api/products/{productId} → DeleteProduct
3838
```
3939

4040
No attributes required. The source generator infers everything from your message names and properties:
4141

4242
- **HTTP method** — from the message name prefix (`Get*` → GET, `Create*` → POST, `Update*` → PUT, `Delete*` → DELETE, etc.)
43-
- **Route** — from the class name (minus the `Handler`/`Consumer` suffix) and message properties (names ending in `Id` become route parameters)
43+
- **Route** — from the message name (minus the verb prefix), **auto-pluralized** to follow REST conventions, with message properties (names ending in `Id` become route parameters)
4444
- **Parameter binding** — ID properties go in the route, other properties become query parameters (GET/DELETE) or body (POST/PUT/PATCH)
4545
- **OpenAPI metadata** — operation names, status codes, and even error responses are auto-detected from your `Result` factory calls
4646
- **Result mapping**`Result<T>` return values are automatically converted to the correct HTTP status codes
@@ -317,6 +317,30 @@ public class ProductHandler
317317
| `"products"` (relative) | `"/status"` (absolute) | `/status` |
318318
| `"/v2/products"` (absolute) | `"{productId}"` (relative) | `/v2/products/{productId}` |
319319

320+
### Route Pluralization
321+
322+
Entity names in convention-based routes are **automatically pluralized** to follow REST conventions. The entity name is extracted from the message name by removing the verb prefix (e.g., `GetProduct``Product`), then pluralized before being converted to kebab-case.
323+
324+
| Message Name | Entity | Pluralized Route |
325+
| ------------ | ------ | ---------------- |
326+
| `GetProduct` | Product | `/products/{productId}` |
327+
| `GetProducts` | Products | `/products` (already plural) |
328+
| `CreateTodo` | Todo | `/todos` |
329+
| `GetCategory` | Category | `/categories/{categoryId}` |
330+
| `GetPerson` | Person | `/people/{personId}` |
331+
| `GetHealth` | Health | `/health` (uncountable) |
332+
333+
**Irregular nouns** are handled automatically: `Person``People`, `Child``Children`, `Index``Indices`, `Criterion``Criteria`, etc.
334+
335+
**Uncountable nouns** are not pluralized: `Health`, `Status`, `Data`, `Info`, `Auth`, `Config`, `Feedback`, `Metadata`, `Settings`, `Media`, `Cache`, `Analytics`, `Telemetry`, `Search`, `Content`, `Access`.
336+
337+
To override auto-pluralization, use an explicit route:
338+
339+
```csharp
340+
[HandlerEndpoint(Route = "/custom-path/{id}")]
341+
public Result<Item> Handle(GetItem query) { ... }
342+
```
343+
320344
### Route Parameters
321345

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

docs/guide/getting-started.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,8 @@ app.MapMediatorEndpoints();
100100

101101
This generates:
102102

103-
- `POST /api/todo``TodoHandler.Handle(CreateTodo)`
104-
- `GET /api/todo/{id}``TodoHandler.Handle(GetTodo)`
103+
- `POST /api/todos``TodoHandler.Handle(CreateTodo)`
104+
- `GET /api/todos/{id}``TodoHandler.Handle(GetTodo)`
105105

106106
<!-- -->
107107

src/Foundatio.Mediator/HandlerAnalyzer.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -811,8 +811,8 @@ private static string GenerateRoute(
811811
// Otherwise, we need to include the entity name in the route
812812
if (string.IsNullOrEmpty(categoryRoutePrefix))
813813
{
814-
// No category prefix - include entity name in route
815-
var kebabEntity = entityName.ToKebabCase();
814+
// No category prefix - include pluralized entity name in route
815+
var kebabEntity = entityName.SimplePluralize().ToKebabCase();
816816
if (!string.IsNullOrEmpty(kebabEntity))
817817
{
818818
parts.Add(kebabEntity);
@@ -844,7 +844,7 @@ private static string GenerateRoute(
844844
else
845845
{
846846
// Entity doesn't match category — include it to avoid ambiguity
847-
parts.Add(actionVerb + "-" + entityName.ToKebabCase());
847+
parts.Add(actionVerb + "-" + entityName.SimplePluralize().ToKebabCase());
848848
}
849849
}
850850

src/Foundatio.Mediator/Utility/Helpers.cs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,4 +123,69 @@ public static string ToKebabCase(this string name)
123123
}
124124
return result.ToString();
125125
}
126+
127+
private static readonly HashSet<string> _uncountableNouns = new(StringComparer.OrdinalIgnoreCase)
128+
{
129+
"Health", "Status", "Data", "Info", "Auth", "Config", "Feedback",
130+
"Metadata", "Settings", "Media", "Cache", "Analytics", "Telemetry",
131+
"Search", "Content", "Access"
132+
};
133+
134+
private static readonly Dictionary<string, string> _irregularNouns = new(StringComparer.OrdinalIgnoreCase)
135+
{
136+
["Person"] = "People",
137+
["Child"] = "Children",
138+
["Man"] = "Men",
139+
["Woman"] = "Women",
140+
["Mouse"] = "Mice",
141+
["Goose"] = "Geese",
142+
["Tooth"] = "Teeth",
143+
["Foot"] = "Feet",
144+
["Ox"] = "Oxen",
145+
["Index"] = "Indices",
146+
["Matrix"] = "Matrices",
147+
["Vertex"] = "Vertices",
148+
["Criterion"] = "Criteria",
149+
["Datum"] = "Data",
150+
["Curriculum"] = "Curricula"
151+
};
152+
153+
/// <summary>
154+
/// Simple English pluralization for route generation.
155+
/// Handles common suffix rules, irregular nouns, and uncountable nouns.
156+
/// </summary>
157+
public static string SimplePluralize(this string name)
158+
{
159+
if (String.IsNullOrEmpty(name))
160+
return name;
161+
162+
if (_uncountableNouns.Contains(name))
163+
return name;
164+
165+
if (_irregularNouns.TryGetValue(name, out var irregular))
166+
return irregular;
167+
168+
// Already ends with 's' — assume already plural
169+
if (name.EndsWith("s", StringComparison.OrdinalIgnoreCase))
170+
return name;
171+
172+
// Consonant + y → ies (e.g., Category → Categories)
173+
if (name.EndsWith("y", StringComparison.OrdinalIgnoreCase) && name.Length >= 2)
174+
{
175+
var preceding = name[name.Length - 2];
176+
if (!"aeiouAEIOU".Contains(preceding))
177+
return name.Substring(0, name.Length - 1) + "ies";
178+
}
179+
180+
// s, x, z, ch, sh → es
181+
if (name.EndsWith("x", StringComparison.OrdinalIgnoreCase) ||
182+
name.EndsWith("z", StringComparison.OrdinalIgnoreCase) ||
183+
name.EndsWith("ch", StringComparison.OrdinalIgnoreCase) ||
184+
name.EndsWith("sh", StringComparison.OrdinalIgnoreCase))
185+
{
186+
return name + "es";
187+
}
188+
189+
return name + "s";
190+
}
126191
}

tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.EndpointGeneration.verified.txt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,8 @@ public static partial class Tests_MediatorEndpoints
166166
// Default endpoints
167167
var defaultGroup = rootGroup.MapGroup("");
168168

169-
// GET /widget/{id} - GetWidget
170-
defaultGroup.MapGet("/widget/{id}", (string id, Foundatio.Mediator.IMediator mediator, System.Threading.CancellationToken cancellationToken) =>
169+
// GET /widgets/{id} - GetWidget
170+
defaultGroup.MapGet("/widgets/{id}", (string id, Foundatio.Mediator.IMediator mediator, System.Threading.CancellationToken cancellationToken) =>
171171
{
172172
var message = new GetWidget(Id: id);
173173
var result = global::Foundatio.Mediator.Generated.WidgetHandler_GetWidget_Handler.Handle(mediator, message, cancellationToken);
@@ -184,7 +184,7 @@ public static partial class Tests_MediatorEndpoints
184184
{
185185
System.Action<string> writeLog = System.Console.WriteLine;
186186
writeLog("Foundatio.Mediator mapped 1 endpoint(s):");
187-
writeLog(" GET /api/widget/{id} → WidgetHandler.Handle(GetWidget) (convention)");
187+
writeLog(" GET /api/widgets/{id} → WidgetHandler.Handle(GetWidget) (convention)");
188188
}
189189
else
190190
{
@@ -448,4 +448,4 @@ public static class WidgetHandler_GetWidget_Handler
448448

449449
}
450450
]
451-
}
451+
}

tests/Foundatio.Mediator.Tests/EndpointGenerationTests.cs

Lines changed: 171 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -871,8 +871,8 @@ public class OrderHandler
871871
// Should be POST (action verb "Approve" implies write operation)
872872
Assert.Contains("MapPost", endpointSource);
873873
// Route should include entity name, ID route param, and action verb suffix
874-
// ApproveOrder(string OrderId) → POST /order/{orderId}/approve
875-
Assert.Contains("/order/{orderId}/approve", endpointSource);
874+
// ApproveOrder(string OrderId) → POST /orders/{orderId}/approve
875+
Assert.Contains("/orders/{orderId}/approve", endpointSource);
876876
// All properties covered by route — no body binding
877877
Assert.DoesNotContain("FromBody", endpointSource);
878878
}
@@ -1687,5 +1687,174 @@ public class ProductHandler
16871687
Assert.NotNull(endpointSource);
16881688
Assert.Contains(".WithSummary(\"GetProductDetails\")", endpointSource);
16891689
}
1690+
1691+
[Fact]
1692+
public void SingularAndPluralMessages_ProduceSamePluralRoute()
1693+
{
1694+
// GetTodo and GetTodos should both generate routes under /todos
1695+
var source = """
1696+
using Foundatio.Mediator;
1697+
1698+
[assembly: MediatorConfiguration(EndpointDiscovery = EndpointDiscovery.All)]
1699+
1700+
public record GetTodo(string Id);
1701+
public record GetTodos();
1702+
1703+
public class TodoHandler
1704+
{
1705+
public string Handle(GetTodo query) => "one";
1706+
public string[] Handle(GetTodos query) => [];
1707+
}
1708+
""";
1709+
1710+
var refs = GetAspNetCoreReferences();
1711+
if (refs.Length == 0) return;
1712+
1713+
var (_, _, trees) = RunGenerator(source, [Gen], additionalReferences: refs);
1714+
var endpointSource = trees.FirstOrDefault(t => t.HintName == "_MediatorEndpoints.g.cs").Source;
1715+
1716+
Assert.NotNull(endpointSource);
1717+
// GetTodo should pluralize to /todos/{id}
1718+
Assert.Contains("/todos/{id}", endpointSource);
1719+
// GetTodos is already plural → /todos
1720+
Assert.Contains("MapGet(\"/todos\"", endpointSource);
1721+
}
1722+
1723+
[Fact]
1724+
public void UncountableNoun_RouteNotPluralized()
1725+
{
1726+
var source = """
1727+
using Foundatio.Mediator;
1728+
1729+
[assembly: MediatorConfiguration(EndpointDiscovery = EndpointDiscovery.All)]
1730+
1731+
public record GetHealth();
1732+
1733+
public class HealthHandler
1734+
{
1735+
public string Handle(GetHealth query) => "ok";
1736+
}
1737+
""";
1738+
1739+
var refs = GetAspNetCoreReferences();
1740+
if (refs.Length == 0) return;
1741+
1742+
var (_, _, trees) = RunGenerator(source, [Gen], additionalReferences: refs);
1743+
var endpointSource = trees.FirstOrDefault(t => t.HintName == "_MediatorEndpoints.g.cs").Source;
1744+
1745+
Assert.NotNull(endpointSource);
1746+
// Health is uncountable — should stay /health, not /healths
1747+
Assert.Contains("\"/health\"", endpointSource);
1748+
Assert.DoesNotContain("healths", endpointSource);
1749+
}
1750+
1751+
[Fact]
1752+
public void IrregularNoun_RoutePluralizesCorrectly()
1753+
{
1754+
var source = """
1755+
using Foundatio.Mediator;
1756+
1757+
[assembly: MediatorConfiguration(EndpointDiscovery = EndpointDiscovery.All)]
1758+
1759+
public record GetPerson(string Id);
1760+
1761+
public class PersonHandler
1762+
{
1763+
public string Handle(GetPerson query) => "person";
1764+
}
1765+
""";
1766+
1767+
var refs = GetAspNetCoreReferences();
1768+
if (refs.Length == 0) return;
1769+
1770+
var (_, _, trees) = RunGenerator(source, [Gen], additionalReferences: refs);
1771+
var endpointSource = trees.FirstOrDefault(t => t.HintName == "_MediatorEndpoints.g.cs").Source;
1772+
1773+
Assert.NotNull(endpointSource);
1774+
// Person → People (irregular)
1775+
Assert.Contains("/people/{id}", endpointSource);
1776+
Assert.DoesNotContain("/persons", endpointSource);
1777+
}
1778+
1779+
[Fact]
1780+
public void ConsonantY_PluralizesToIes()
1781+
{
1782+
var source = """
1783+
using Foundatio.Mediator;
1784+
1785+
[assembly: MediatorConfiguration(EndpointDiscovery = EndpointDiscovery.All)]
1786+
1787+
public record GetCategory(string Id);
1788+
1789+
public class CategoryHandler
1790+
{
1791+
public string Handle(GetCategory query) => "cat";
1792+
}
1793+
""";
1794+
1795+
var refs = GetAspNetCoreReferences();
1796+
if (refs.Length == 0) return;
1797+
1798+
var (_, _, trees) = RunGenerator(source, [Gen], additionalReferences: refs);
1799+
var endpointSource = trees.FirstOrDefault(t => t.HintName == "_MediatorEndpoints.g.cs").Source;
1800+
1801+
Assert.NotNull(endpointSource);
1802+
// Category → Categories (consonant + y → ies)
1803+
Assert.Contains("/categories/{id}", endpointSource);
1804+
}
1805+
1806+
[Fact]
1807+
public void VowelY_PluralizesWithS()
1808+
{
1809+
var source = """
1810+
using Foundatio.Mediator;
1811+
1812+
[assembly: MediatorConfiguration(EndpointDiscovery = EndpointDiscovery.All)]
1813+
1814+
public record GetKey(string Id);
1815+
1816+
public class KeyHandler
1817+
{
1818+
public string Handle(GetKey query) => "key";
1819+
}
1820+
""";
1821+
1822+
var refs = GetAspNetCoreReferences();
1823+
if (refs.Length == 0) return;
1824+
1825+
var (_, _, trees) = RunGenerator(source, [Gen], additionalReferences: refs);
1826+
var endpointSource = trees.FirstOrDefault(t => t.HintName == "_MediatorEndpoints.g.cs").Source;
1827+
1828+
Assert.NotNull(endpointSource);
1829+
// Key → Keys (vowel + y → just s)
1830+
Assert.Contains("/keys/{id}", endpointSource);
1831+
}
1832+
1833+
[Fact]
1834+
public void SibilantEnding_PluralizesWithEs()
1835+
{
1836+
var source = """
1837+
using Foundatio.Mediator;
1838+
1839+
[assembly: MediatorConfiguration(EndpointDiscovery = EndpointDiscovery.All)]
1840+
1841+
public record GetBatch(string Id);
1842+
1843+
public class BatchHandler
1844+
{
1845+
public string Handle(GetBatch query) => "batch";
1846+
}
1847+
""";
1848+
1849+
var refs = GetAspNetCoreReferences();
1850+
if (refs.Length == 0) return;
1851+
1852+
var (_, _, trees) = RunGenerator(source, [Gen], additionalReferences: refs);
1853+
var endpointSource = trees.FirstOrDefault(t => t.HintName == "_MediatorEndpoints.g.cs").Source;
1854+
1855+
Assert.NotNull(endpointSource);
1856+
// Batch → Batches (ch → es)
1857+
Assert.Contains("/batches/{id}", endpointSource);
1858+
}
16901859
}
16911860

0 commit comments

Comments
 (0)