A .NET 6 Minimal API Todo example Playground
I really like minimal Web APIs. I've liked the idea for years. With .NET 6, it's starting to happen! Damian Edwards has an interesting minimal API Playground on his GitHub and Maria Naggaga did a great talk on Minimal APIs in .NET 6 that's up on YouTube!
Let's explore! I'm running the latest .NET 6 and you can run it on Windows, Mac, or Linux and I cloned it to a folder locally.
There's two versions of a complete Todo API in this sample, one using Entity Framework Core and one using Dapper for data access. Both are lightweight ORMs (object relational mappers). Let's explore the Dapper example that uses SQLite.
The opening of the code in this example doesn't require a Main() which removes a nice bit of historically unneeded syntactic sodium. The Main is implied.
using System.ComponentModel.DataAnnotations;
using Microsoft.Data.Sqlite;
using Dapper;
var builder = WebApplication.CreateBuilder(args);
var connectionString = builder.Configuration.GetConnectionString("TodoDb") ?? "Data Source=todos.db";
builder.Services.AddScoped(_ => new SqliteConnection(connectionString));
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
At this point we've got a SQLite connection string ready to go scoped in the Services Dependency Injection Container (fancy words for "it's in the pile of stuff we'll be using later") and we've told the system we want a nice UI for our Open API (Swagger) web services description. It's WSDL for JSON, kids!
Then a call to EnsureDb which, ahem, ensures there's a database!
await EnsureDb(app.Services, app.Logger);
What's it look like? Just a little make this table if it doesn't exist action:
async Task EnsureDb(IServiceProvider services, ILogger logger)
{
logger.LogInformation("Ensuring database exists at connection string '{connectionString}'", connectionString);
using var db = services.CreateScope().ServiceProvider.GetRequiredService<SqliteConnection>();
var sql = $@"CREATE TABLE IF NOT EXISTS Todos (
{nameof(Todo.Id)} INTEGER PRIMARY KEY AUTOINCREMENT,
{nameof(Todo.Title)} TEXT NOT NULL,
{nameof(Todo.IsComplete)} INTEGER DEFAULT 0 NOT NULL CHECK({nameof(Todo.IsComplete)} IN (0, 1))
);";
await db.ExecuteAsync(sql);
}
Next we'll "map" some paths for /error as well as paths for our API's UI so when I hit /swagger with a web browser it looks nice:
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/error");
}
app.MapGet("/error", () => Results.Problem("An error occurred.", statusCode: 500))
.ExcludeFromDescription();
app.MapSwagger();
app.UseSwaggerUI();
Then sprinkle in a little Hello World just to give folks a taste:
app.MapGet("/", () => "Hello World!")
.WithName("Hello");
app.MapGet("/hello", () => new { Hello = "World" })
.WithName("HelloObject");
You can see how /hello would return a JSON object of Hello: "World"
What's that WithName bit at the end? That names the API and corresponds to 'operationId" in the generated swagger/openAPI json file. It's a shorthand for
.WithMetadata(new EndpointNameMetadata("get_product"));
which was surely no fun at all.
Now let's get some Todos from this database, shall we? Here's all of them and just the complete ones:
app.MapGet("/todos", async (SqliteConnection db) =>
await db.QueryAsync<Todo>("SELECT * FROM Todos"))
.WithName("GetAllTodos");
app.MapGet("/todos/complete", async (SqliteConnection db) =>
await db.QueryAsync<Todo>("SELECT * FROM Todos WHERE IsComplete = true"))
.WithName("GetCompleteTodos");
Lovely. But what's this Todo object? We haven't seen that. It's just a object that's shaped right. Perhaps one day that could be a record rather than a class but neither Dapper or EFCore support that yet it seems. Still, it's minimal.
public class Todo
{
public int Id { get; set; }
[Required]
public string? Title { get; set; }
public bool IsComplete { get; set; }
}
Let's get a little fancier with an API that gets a Todo but it might not find the result! It may produce an HTTP 200 OK or an HTTP 404 NotFound.
app.MapGet("/todos/{id}", async (int id, SqliteConnection db) =>
await db.QuerySingleOrDefaultAsync<Todo>("SELECT * FROM Todos WHERE Id = @id", new { id })
is Todo todo
? Results.Ok(todo)
: Results.NotFound())
.WithName("GetTodoById")
.Produces<Todo>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
Don't be sad if you don't like SQL like this, it's just a choice amongst many. You can use whatever ORM you want, worry not.
A thought: The .Produces are used by the OpenAPI/Swagger system. In my mind, it'd be nice to avoid saying it twice as the Results.Ok and Results.NotFound is sitting right there, but you'd need a Source Generator or aspect-oriented post compilation weaver to tack in on after the fact. This is the only part that I don't like.
Go explore the code and check it out for yourself!
Check out our Sponsor! YugabyteDB is a distributed SQL database designed for resilience and scale. It is 100% open source, PostgreSQL-compatible, enterprise-grade, and runs across all clouds. Sign up and get a free t-shirt!
About Scott
Scott Hanselman is a former professor, former Chief Architect in finance, now speaker, consultant, father, diabetic, and Microsoft employee. He is a failed stand-up comic, a cornrower, and a book author.
About Newsletter
Comments are closed.