Skip to content

User learning arc for new minimal/focused ASP.NET Core API experience #30580

Closed
@DamianEdwards

Description

@DamianEdwards

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; }
}

Metadata

Metadata

Labels

User StoryA single user-facing feature. Can be grouped under an epic.area-minimalIncludes minimal APIs, endpoint filters, parameter binding, request delegate generator etcfeature-minimal-actionsController-like actions for endpoint routingold-area-web-frameworks-do-not-use*DEPRECATED* This label is deprecated in favor of the area-mvc and area-minimal labels

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions