Skip to content

REST API — Generated Code

The WebEndpointGenerator (triggered by [RestApi]) produces three categories of code: endpoint mappings, bootstrap extensions, and auth endpoints (when [WebAuth] is present). It also produces TypeScript client types for full-stack type safety.

Endpoint Mappings

For each handler annotated with an HTTP method attribute, the generator produces a Minimal API mapping inside a Map{RuntimeName}() extension method.

Input

Runtime.cs
[Runtime]
public partial class CatalogRuntime;

[DataStore]
public static partial class CatalogStore;

[DispatchModule]
public static partial class CatalogDispatch;

[RestApi(RoutePrefix = "/api/catalog", Title = "eShop Catalog API")]
public partial class CatalogApp;
Domain/Queries/GetCatalogItemById.cs
public sealed record GetCatalogItemById(CatalogItemId Id) : IQuery;

public static class GetCatalogItemByIdHandler
{
    [QueryHandler, HttpGet("/items/{id}")]
    public static Eff<CatalogRuntime, Option<CatalogItem>> Handle(GetCatalogItemById query) =>
        CatalogStore.CatalogItems.GetById<CatalogRuntime>(query.Id);
}
Domain/Commands/UpdateCatalogItemPrice.cs
public sealed record UpdateCatalogItemPrice(CatalogItemId ItemId, decimal NewPrice) : ICommand;

public static class UpdateCatalogItemPriceHandler
{
    [CommandHandler, HttpPut("/items/{catalogItemId}/price")]
    public static Eff<CatalogRuntime, ProductPriceChanged> Handle(UpdateCatalogItemPrice cmd) =>
        from item in CatalogStore.CatalogItems.Require<CatalogRuntime>(cmd.ItemId)
        let oldPrice = item.Price
        from _ in Mutate<CatalogRuntime>(() => item.Price = cmd.NewPrice)
                  >> CatalogStore.CatalogItems.Save<CatalogRuntime>(item)
        select new ProductPriceChanged(cmd.ItemId, oldPrice, cmd.NewPrice);
}

Output

public static class CatalogAppExtensions
{
    public static WebApplication MapCatalogApp(this WebApplication app)
    {
        var api = app.MapGroup("/api/catalog");

        api.MapGet("/items/{id}", async (
            [AsParameters] GetCatalogItemById query,
            CatalogRuntime rt) =>
            (await CatalogDispatch.Dispatch(query).RunAsync(rt)).Match(
                Succ: option => option.Match(
                    Some: value => Results.Ok(value),
                    None: () => Results.NotFound()),
                Fail: error => WebError.ToResult(error)));

        api.MapPut("/items/{catalogItemId}/price", async (
            UpdateCatalogItemPrice cmd,
            CatalogRuntime rt) =>
            (await CatalogDispatch.Dispatch(cmd).RunAsync(rt)).Match(
                Succ: _ => Results.NoContent(),
                Fail: error => WebError.ToResult(error)));

        return app;
    }
}

Key behaviors:

  • GET queries bind via [AsParameters] — record properties become route/query string params
  • POST/PUT/PATCH commands deserialize the request body
  • Option<T> return types automatically produce 404 on None
  • [Public] handlers skip the authorization requirement
  • Errors return appropriate HTTP status codes via WebError.ToResult()

Bootstrap Extension

The generator produces an Add{RuntimeName}() extension method for DI registration with a typed options class.

public static class CatalogAppExtensions
{
    public static IServiceCollection AddCatalogApp(
        this IServiceCollection services,
        Action<CatalogAppRestApiOptions>? configure = null)
    {
        var options = new CatalogAppRestApiOptions();
        configure?.Invoke(options);

        services.AddCors(cors =>
            cors.AddDefaultPolicy(policy =>
                policy.WithOrigins(options.CorsOrigins)
                      .AllowAnyMethod()
                      .AllowAnyHeader()));

        if (options.OpenApi)
            services.AddOpenApi();

        services.ConfigureHttpJsonOptions(json =>
            json.SerializerOptions.PropertyNamingPolicy = options.NamingPolicy);

        return services;
    }
}

RestApiOptions class

public sealed class CatalogAppRestApiOptions
{
    public string[] CorsOrigins { get; set; } = ["*"];
    public bool OpenApi { get; set; } = true;
    public JsonNamingPolicy NamingPolicy { get; set; } = JsonNamingPolicy.CamelCase;

    public CatalogAppRestApiOptions WithCorsOrigins(params string[] origins) { ... }
    public CatalogAppRestApiOptions EnableOpenApi() { ... }
    public CatalogAppRestApiOptions DisableOpenApi() { ... }
    public CatalogAppRestApiOptions UseCamelCase() { ... }
    public CatalogAppRestApiOptions UseSnakeCase() { ... }
}

Auth Endpoints

When [WebAuth("google")] is present on the [RestApi] class, the generator adds auth endpoint mappings:

// Inside MapCatalogApp():
api.MapPost("/auth/google/login", AuthEndpoints.HandleGoogleLogin);
api.MapPost("/auth/refresh", AuthEndpoints.HandleRefreshToken);
api.MapPost("/auth/logout", AuthEndpoints.HandleLogout);

The AuthEndpoints static class delegates to IJwtService, IGoogleTokenVerifier, and IRefreshTokenStore from DI.

TypeScript Output

The generator also produces TypeScript files for full-stack type safety:

Types

export interface GetCatalogItemByIdQuery {
    id: string;
}

export interface GetCatalogItemByIdResult {
    item: CatalogItem;
}

export interface UpdateCatalogItemPriceCommand {
    id: string;
    newPrice: number;
}

export interface UpdateCatalogItemPriceResult {
    oldPrice: number;
    newPrice: number;
}

Client

export class CatalogClient {
    constructor(private baseUrl: string, private getToken: () => string | null) {}

    async getCatalogItemById(query: GetCatalogItemByIdQuery): Promise<GetCatalogItemByIdResult> {
        return this.get(`/api/catalog/items/${query.id}`);
    }

    async updateCatalogItemPrice(command: UpdateCatalogItemPriceCommand): Promise<UpdateCatalogItemPriceResult> {
        return this.post(`/api/catalog/items/${command.id}/price`, command);
    }
}

C# records map to TypeScript interfaces. TypedId properties become string. decimal becomes number. Collections become arrays.