How it works
The files and members the generator emits per endpoint, per route group and per assembly.
Immediate.Apis hooks the same [Handler] attribute that Immediate.Handlers does, then looks for a Map attribute on the class. Everything below is written at compile time; nothing is discovered at
startup. For the general mechanics and how to inspect generated files, see How source generation works.
The generator fully qualifies type names with global::. Those prefixes are omitted
from the listings below for readability.
The files
| File | Contents |
|---|---|
IA.<Namespace>.<Class>.g.cs | One per endpoint |
IA.<Namespace>..<Group>.g.cs | One per top-level route group, including its children |
IA.MapEndpoints.g.cs | One per assembly |
The route-group file name is built from the namespace, the outer class names and the group class
name. A top-level group has no outer classes, so the name contains a doubled dot — IA.Dummy..Root.g.cs. That is expected, not a bug in your project.
Per endpoint
For a handler like this:
[Handler]
[MapGet("/users")]
public static partial class GetUsers
{
public sealed record Query;
private static ValueTask<IReadOnlyList<User>> HandleAsync(
Query _,
UsersService usersService,
CancellationToken token
) => usersService.GetUsersAsync(token);
}the generator adds three members to the partial class (simplified):
/// <remarks><see cref="Users.GetUsers.Query" /> registered using
/// <c>[<see cref="Microsoft.AspNetCore.Http.AsParametersAttribute" />]</c></remarks>
partial class GetUsers
{
[GeneratedCode("Immediate.Apis", "…")]
internal static void MapEndpoint(IEndpointRouteBuilder app)
{
var endpoint = EndpointRouteBuilderExtensions.MapGet(
app,
"/users",
async (
[AsParametersAttribute] Users.GetUsers.Query parameters,
[FromServices] Users.GetUsers.Handler handler,
CancellationToken token
) =>
{
var ret = await handler.HandleAsync(parameters, token);
return ret;
}
);
}
[GeneratedCode("Immediate.Apis", "…")]
public static IReadOnlyList<string> Routes { get; } = ["/users"];
[GeneratedCode("Immediate.Apis", "…")]
public static string Route { get; } = "/users";
}Points worth knowing:
- The delegate resolves your handler with
[FromServices] ….Handler, which is the class Immediate.Handlers generates. That is whyAddXxxHandlers()is still required. - The
<remarks>comment records which binding attribute was inferred — the quickest way to check binding without reading the whole file. - With several routes, the body repeats the
MapGetcall once per route, reassigningendpointeach time. Authorization andCustomizeEndpointare applied inside that loop, so each route gets them. - The order inside
MapEndpointis fixed: map the route, thenAllowAnonymous()orRequireAuthorization(...), thenCustomizeEndpoint(endpoint). MapEndpointisinternal, so it is callable from your own composition code within the same assembly if you ever need to map one endpoint by hand.
Route and Routes
Routes is always emitted. Route is emitted only when the attribute declares exactly one
route — with two or more, referencing Route is a compile error rather than a silent pick.
These properties are the reason to stop hardcoding URLs in tests and link generation:
var response = await client.GetAsync(GetUsers.Route);foreach (var route in GetUsers.Routes)
{
// ...
}They are public and carry no [EditorBrowsable] restriction, so they show up in IntelliSense on the
handler class.
Per assembly
Every assembly with at least one endpoint gets IA.MapEndpoints.g.cs:
public static partial class ApisServiceCollectionExtensions
{
public static RouteGroupBuilder MapUsersApiEndpoints(
this IEndpointRouteBuilder app,
[StringSyntax("Route")] string prefix = "",
params ReadOnlySpan<string> tags
)
{
var group = EndpointRouteBuilderExtensions.MapGroup(app, prefix);
Users.GetUsers.MapEndpoint(group);
if (tags is [] || Intersects(tags, ["Admin"]))
{
Users.Admin.MapGroup(group, tags);
}
return group;
}
private static bool Intersects(ReadOnlySpan<string> first, ReadOnlySpan<string> second) { /* ... */ }
}- The class is always called
ApisServiceCollectionExtensionsand is emitted in the global namespace, so nousingis needed inProgram.cs. The method name is what carries the assembly identifier. - Everything is wrapped in a
MapGroup(prefix), and thatRouteGroupBuilderis returned, so conventions chained onto the call apply to every endpoint in the assembly. - Untagged endpoints are emitted without a guard; tagged ones sit inside an
Intersectscheck. See Tagged registration. - Only endpoints without a
[MapGroup<T>]and route groups that are not nested in another group appear here. Everything else is reached through a group. - On C# 12 and below
tagsis generated asparams string[].
Per route group
One file is emitted per top-level route group, containing the whole nested hierarchy as nested partial classes. Each group class gets:
partial class Admin
{
[GeneratedCode("Immediate.Apis", "…")]
internal static void MapGroup(IEndpointRouteBuilder app, ReadOnlySpan<string> tags)
{
var group = EndpointRouteBuilderExtensions.MapGroup(app, "api/admin");
CustomizeGroup(group);
Users.DeleteUser.MapEndpoint(group);
Users.Admin.Reports.MapGroup(group, tags);
}
public const string DeleteUser = "api/admin/users/{id:int}";
partial class Reports { /* ... */ }
}CustomizeGroup(group)is called first, before any endpoint or child group is mapped, and only when a validCustomizeGroupmethod exists.tagsis threaded down the hierarchy so filtering applies at every level.- One
public const stringis emitted per endpoint in the group that has exactly one route, named after the endpoint class. Its value is the group prefixes and the endpoint route joined with/, with no normalization. - The top-level group class also receives the same
private static bool Intersects(...)helper used by the assembly registration.