Description
This code sample is intended to represent the coming together of the various efforts associated with "simplification" and "scaling-down" of C# HTTP API apps into a progressive narrative example of how one might learn or be taught the concepts with reasonably scaled steps during the grow-up journey.
This example uses a number of features (some existing, some committed, some proposed) including:
- C# top level statements
- C# global usings
- C# using static directive
- C# implicit MethodGroup -> Delegate cast
- C# type deconstruction, e.g.
var (todo, isValid) = inputTodo; // Validated<Todo> inputTodo
- C# pattern matching, e.g.
return await db.Todos.FindAsync(id) is Todo todo ? Ok(todo) : NotFound();
- single-file apps (i.e. runnable single file projects, e.g.
dotnet run server.cs
) - simplified ASP.NET Core app host (aka FeatherHTTP, aka "Direct Hosting")
- lightweight programming model for APIs (aka MapAction)
- route definition instances, e.g.
var myRoute = HttpGet("todo/{id}"); myRoute.Url(todo.Id);
- higher level service registration methods, e.g.
builder.AddSqlite<EntityType>(string connectionString)
- non-DI scenarios
Not all concepts are necessarily required for an MVP (e.g. in an early preview) and we should capture details of how we think about that (TODO), e.g. #!
support & global usings aren't required for this to be valuable but they naturally build on the MVP in a way that adds further value.
We'll update this sample in-place as it evolves. In-progress iterations can be captured in gists and linked to in comments, etc.
#!/usr/bin/env dotnet run
var builder = WebApplication.CreateBuilder(args);
var config = builder.Configuration;
var connString = config["connectionString"] ?? "Data Source=todos.db";
builder.AddDbContext<TodoDb>(options => options.UseSqlite(connString));
builder.AddSqlite<Todo>(connString) // Higher level API perhaps?
var app = builder.Build();
// Step 0: Request delegate
app.MapGet("/", ctx => ctx.Respsone.WriteAsync("Hello, World!"));
// Step 1: Return string
app.MapGet("/hello", () => "Hello, World!");
// Step 2: Return custom type
app.MapGet("/todo", () => new Todo("Do the thing"));
// Step 3a: Use a DB (no DI)
app.MapGet("/todos/{id}", async (int id) =>
{
using var db = new TodoDb(connString);
return await db.Todos.FindAsync(id) is Todo todo
? Ok(todo) : NotFound();
});
// Step 3b: Use a DB (with DI)
app.MapGet("/todos/{id}", async (int id, TodoDb db) =>
{
return await db.Todos.FindAsync(id) is Todo todo
? Ok(todo) : NotFound();
});
app.MapPost("/todos", async (Todo todo, TodoDb db) =>
{
db.Todos.Add(todo);
await Todos.SaveChangesAsync();
return CreatedAt($"/todo-db/{todo.Id}", todo);
};
app.MapPut("/todos", async (int id, Todo inputTodo, TodoDb db) =>
{
var todo = await db.Todos.FindAsync(id);
if (todo is null) return NotFound();
todo.Title = inputTodo.Title;
todo.IsComplete = inputTodo.IsComplete;
await db.SaveChanges();
return NoContent();
});
app.MapDelete("/todos", async (int id, TodoDb db) =>
{
if (await db.Todos.FindAsync(id) is Todo todo)
{
db.Todos.Remove(todo);
await db.SaveChanges();
return OK(todo);
}
return NotFound();
});
// Step 4: Referenced routes
var routes = new {
GetTodo = HttpGet("/todos/{id}")
};
app.MapGet(routes.GetTodo, async (int id, TodoDb db) =>
{
return await db.Todos.FindAsync(id) is Todo todo
? Ok(todo) : NotFound();
});
app.MapPost("/todos", async (Todo todo, TodoDb db) =>
{
db.Todos.Add(todo);
await Todos.SaveChangesAsync();
return CreatedAt(routes.GetTodo.Url(todo.Id), todo);
};
// Step 5: Input validation
app.MapPost("/todos", async (Validated<Todo> inputTodo, TodoDb db) =>
{
var (todo, isValid) = inputTodo;
if (!isValid) return Problem(inputTodo);
db.Todos.Add(todo);
await Todos.SaveChangesAsync();
return CreatedAt(routes.GetTodo.Url(todo.Id), todo);
};
// Run app
await app.RunAsync();
// Data types
record Todo(string Title)
{
public bool IsComplete { get; set; }
}
// DI scenario
class TodoDb : DbContext
{
// Rough edge here potentially with this constructor signature being quite verbose
// Can we improve this somehow? Ideally this constructor wouldn't be required by default at all
public TodoDb(DbContextOptions<TodoDb> options)
: base(options)
{
}
public DbSet<Todo> Todos { get; set; }
}
// Non-DI use scenario, this is pretty rough right now, explore improvements?
class TodoDb : DbContext
{
private readonly string _cs;
public TodoDb(string connectionString) : base() => _cs = connectionString;
protected override OnConfiguring(DbContextOptionsBuilder optionsBuilder) // Non-DI use scenario
{
optionsBuilder.UseSqlite(_cs);
}
public DbSet<Todo> Todos { get; set; }
}