Skip to content

Repository files navigation

Elsie

HTTP module framework for .NET 8 and .NET 10. Define routes in small modules, return results, host on ASP.NET Core.

dotnet add package Elsie          # 0.3.0-alpha.1 — pulls Elsie.Web + Elsie.Core
using Elsie;
using Elsie.Web;

ElsieWeb.Run<App>(args);

public sealed class App : ElsieModule
{
    public App()
    {
        Get("/", () => ElsieResult.Text("Hello, Elsie!"));
        Get("/hello/{name}", ctx =>
            ElsieResult.Text($"Hello {ctx.RouteOrDefault("name")}!"));
    }
}
dotnet run
# GET /           → Hello, Elsie!
# GET /hello/Ada  → Hello Ada!

Templates:

dotnet new install Elsie.Templates
dotnet new elsie -n HelloApp        # minimal app
dotnet new elsie-api -n TodosApi    # CRUD + API key + OpenAPI

Guides: docs/ · Samples: HelloWorld · Hello · Api · Views · Dashboard · Full · NuGet: Elsie


Quickstart (builder)

var builder = WebApplication.CreateBuilder(args);
builder.AddElsie();                              // DI + quieter console logs
builder.Services.AddElsieModule<TodosModule>();  // or rely on entry-assembly scan

var app = builder.Build();
app.MapElsieOpenApi(o =>
{
    o.Info.Title = "My API";
    o.UiPath = "/scalar";                        // optional Scalar CDN UI
});
app.MapElsie();
app.Run();
API Use
ElsieWeb.Run<T>(args) Smallest host
builder.AddElsie() Wire DI (ScanEntryAssembly = true by default)
AddElsieModule<T>() Explicit module registration (prefer in tests)
app.MapElsie() Map Elsie into the pipeline (non-terminal by default)

Modules are singletons. Inject singleton-safe services in the ctor; resolve request-scoped services with ctx.GetRequiredService<T>() / ctx.Services.

JSON: ElsieResult.Json(...) uses framework defaults (ElsieJson.DefaultOptions); ctx.Json(...) uses app ElsieOptions.JsonSerializerOptions.


Modules and routing

public sealed class TodosModule : ElsieModule
{
    public TodosModule(ITodoStore store)
    {
        Path("/api");
        Before(ElsieAuth.RequireApiKey("dev-secret", onlyMutatingMethods: true));

        Group("/todos", () =>
        {
            Get("/", ctx => ctx.Json(store.List(ctx.QueryOrDefault("q"))))
                .Named("listTodos")
                .WithTags("todos");

            Get("/{id:guid}", ctx =>
            {
                if (!ctx.RequireRoute("id", out Guid id, out var error))
                    return error!;
                return ctx.Json(store.Get(id));
            }).Named("getTodo").Produces<Todo>();

            Post("/", async (ctx, ct) =>
            {
                var bind = await ctx.BindJsonAsync<CreateTodo>(ct);
                if (!bind.IsSuccess) return bind.Error!;
                var created = store.Add(bind.Value!.Title);
                return ElsieResult.Created(ctx.UrlFor("getTodo", new { id = created.Id }), created);
            }).Accepts<CreateTodo>().Produces<Todo>(201);
        });
    }
}

Route templates: {name}, optional {name?}, default {name=5}, constraints {id:int}, catch-all {*path} (last segment only).

Built-in constraints: int, long, guid, bool, alpha, datetime, decimal, double, minlength(n), maxlength(n), length(n|min,max), min(n), max(n), range(a,b), regex(...).

Matching: per segment, static > constrained > param > catch-all. Startup fails on unknown constraints, duplicate param names, bad catch-all placement, ambiguous routes, and duplicate route names. Wrong verb on a known path → 405 + Allow + problem+json. HEAD maps to GET when ElsieOptions.ImplicitHead is on (default).


Results, binding, context

ElsieResult.Text("ok")
ElsieResult.Html("<p>hi</p>")
ElsieResult.Json(payload, statusCode: 201)   // framework JSON defaults
ctx.Json(payload)                            // app JsonSerializerOptions
ElsieResult.Created("/items/1", body)
ElsieResult.Accepted()
ElsieResult.File(bytes, "application/pdf", downloadName: "a.pdf")
ElsieResult.Redirect("/elsewhere")           // 302; 307/308 helpers also
ElsieResult.NoContent()
ElsieResult.NotModified()
ElsieResult.Problem(400, "Bad Request", detail)
ElsieResult.ValidationProblem(errors)
ElsieResult.ServerSentEvents(async (w, ct) => await w.WriteEventAsync("tick", "1", ct))

var bind = await ctx.BindJsonAsync<CreateTodo>(ct);
var form = await ctx.BindFormAsync<LoginForm>(ct);
var query = ctx.BindQuery<SearchQuery>();
ctx.Route<int>("id");
ctx.Query<bool>("shout");
ctx.RequireRoute("id", out Guid id, out var error);

ctx.Request.Method / Path / Scheme / Host / PathBase / Protocol / RemoteIp
ctx.Request.GetHeader / GetQuery / GetCookie
ctx.UrlFor("getTodo", new { id })
ctx.Response.Headers["X-App"] = "1";
ctx.Response.SetCookie("sid", value, new ElsieCookieOptions { HttpOnly = true });
ctx.GetRequiredService<IMyService>();

Exception handling (first match wins):

builder.AddElsie(o =>
{
    o.MapException<KeyNotFoundException>((_, ex) => ElsieResult.NotFound(ex.Message));
    o.ExceptionHandler = (ctx, ex, ct) =>
        Task.FromResult(ElsieResult.Problem(500, "Server Error", ex.Message));
});
// MapException → module OnError → ExceptionHandler → rethrow
// After-hooks still run when the exception is mapped to a result

Need HttpContext? Host escape hatch (core stays free of it):

Get("/trace", ctx =>
    ctx.TryGetHttpContext(out var http)
        ? ElsieResult.Text(http!.TraceIdentifier)
        : ElsieResult.Text("no-host"));

Pipelines

App-wide or per-module before / after hooks. After-hooks may transform the result.

builder.Services.ConfigureElsiePipelines(p =>
{
    p.AddBefore((ctx, _) =>
    {
        ctx.Response.Headers["X-Request-Id"] = Guid.NewGuid().ToString("n");
        return Task.FromResult<ElsieResult?>(null); // null = continue
    });
    p.AddAfter((ctx, result) =>
    {
        ctx.Response.Headers["X-Status"] = result.StatusCode.ToString();
        return result;
    });
});
// Order: app.Before → module.Before → handler → module.After → app.After

Core gates (no ASP.NET auth stack):

Before(ElsieAuth.RequireApiKey("dev-secret"));                    // all methods (default)
Before(ElsieAuth.RequireApiKey("dev-secret", onlyMutatingMethods: true));
Before(ElsieAuth.RequireHeader("X-Tenant", "acme"));
Before(ElsieAuth.RequireBearer(token => token == "ok"));
Before(ElsieAuth.RequireCookie("session"));

Cookie/JWT policies → Elsie.Auth.


Optional packages

Auth — Elsie.Auth

builder.Services.AddElsieAuth(o => o.Cookie = c => c.Cookie.Name = "elsie-auth");
app.UseElsieAuth(); // before MapElsie

Before(ElsieAuthGates.RequireAuthenticated());
Before(ElsieAuthGates.RequireRole("admin"));
await ctx.SignInCookieAsync("ada", roles: ["user"]);
var user = ctx.GetUser();

CORS — Elsie.Cors

Elsie handles OPTIONS preflight itself (ASP.NET UseCors does not see those requests).

builder.Services.AddElsieCors(o => o.AddDefaultPolicy(p => p
    .AllowOrigins("http://localhost:5173")
    .AllowMethods("GET", "POST", "OPTIONS")
    .AllowHeaders("Content-Type")
    .AllowCredentials()));
app.UseElsieCors(); // before MapElsie
// optional: Get(...).WithCors("policy-name")

Health checks (in Elsie.Core)

builder.Services.AddElsieHealthChecks(o =>
{
    o.AddCheck("self", () => ElsieHealthCheckResult.Healthy(), ElsieHealthCheckTags.Live);
    o.AddCheck("db", ct => CheckDbAsync(ct), ElsieHealthCheckTags.Ready);
});
// GET /healthz | /healthz/live | /healthz/ready  (unhealthy → 503)

Rate limiting (in Elsie.Core)

Before(ElsieRateLimit.FixedWindow(100, TimeSpan.FromMinutes(1)));
Before(ElsieRateLimit.SlidingWindow(30, TimeSpan.FromSeconds(10),
    partitionKey: ctx => ctx.Request.GetHeader("X-Api-Key") ?? "anon"));
// 429 problem+json + Retry-After; uses TimeProvider

OpenAPI (in host)

Route metadata (.Named / .Accepts / .Produces / .WithSecurity / …) builds the document.

app.MapElsieOpenApi(o =>
{
    o.Info.Title = "My API";
    o.Info.SecuritySchemes["ApiKey"] = ElsieOpenApiSecurityScheme.ApiKeyHeader();
    o.UiPath = "/scalar";
});

Views — Elsie.Views (Fluid / Liquid)

builder.Services.AddElsieViews(o => o.ContentRoot = builder.Environment.ContentRootPath);
return await ctx.ViewAsync("home", new { Title = "Hi", Name = "Ada" }, cancellationToken: ct);
{% layout '_Layout.liquid' %}
<h1>Hello {{ Name }}!</h1>

Static files (host)

app.UseStaticFiles(new StaticFileOptions
{
    RequestPath = "/assets",
    FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(
        Path.Combine(contentRoot, "wwwroot"))
});
// ASP.NET static files (sendfile, caching, etc.)

Validation (app-level FluentValidation)

Reference FluentValidation in the app; see recipe in docs/binding.md.

Testing — Elsie.Testing

await using var mem = ElsieInMemoryHost.Create(s => s.AddElsieModule<HelloModule>());
var r = await mem.GetAsync("/hello/Ada");
Assert.Equal(200, r.StatusCode);

await using var host = ElsieTestHost.Create(s => s.AddElsieModule<HelloModule>());
var response = await host.GetAsync("/hello/Ada");
response.AssertStatus(200);

In tests, set ScanEntryAssembly = false and register modules explicitly.


Package layout

Package Contents
Elsie Meta-package for apps → Elsie.WebElsie.Core
Elsie.Web ElsieWeb, AddElsie, MapElsie, OpenAPI
Elsie.Core Host-agnostic modules, routing, dispatcher, results, pipelines, health checks, rate limiting
Elsie.Auth Cookie/JWT + auth gates
Elsie.Cors Elsie-native CORS
Elsie.Views Fluid/Liquid views
Elsie.Testing In-memory + TestServer hosts
Elsie.Templates dotnet new elsie / elsie-api

Current version: 0.3.0-alpha.1 (prerelease; APIs may still change).

Namespaces stay Elsie / Elsie.Web regardless of package id. Library authors who want the host-agnostic surface only should reference Elsie.Core.


Request flow

HTTP request
  → host (ASP.NET Core or in-memory test host)
  → ElsieRequest
  → RouteTable.Lookup → before hooks → handler → after hooks → ElsieResult
  → ElsieHttpResponse.FromDispatch
  → host writes status / headers / body

MapElsie is non-terminal by default so OpenAPI, static files, and other endpoints can coexist. MapElsie(terminal: true) returns 404 problem+json for unmatched routes.


Docs

Topic
Getting started Install, smallest app, samples
Modules Registration, DI lifetimes
Routing Templates, constraints, precedence
Results Response factories
Binding Route/query/JSON/form
Pipelines & errors Before/after, exception maps
Auth Core gates + Elsie.Auth
CORS Elsie.Cors
Rate limiting Fixed/sliding windows
Health checks Live/ready
OpenAPI Document + Scalar
Views Fluid/Liquid
Static files ASP.NET UseStaticFiles
Testing In-memory + TestServer
Hosting & AOT Host notes

Changelog: CHANGELOG.md


License

MIT © WavestormSoftwareLICENSE

About

⚠️Work in Progress!! Elsie — lightweight Sinatra-style web framework for .NET

Resources

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages