.net 6 minimal api

Agenda

  • Integration testing
  • Why minimal api ?
  • Application objects
  • Request handling
  • Auth & Auth
  • CORS
  • Open API
  • Exception handling
  • Request data validation

Why ?

The Why!

 

Minimal APIs(Nov. 2019) are architected to create HTTP APIs with minimal dependencies.

 

They are ideal for microservices and apps that want to include only the minimum:

- files,

- features

-  dependencies in ASP.NET Core.

Some advantages

  • A lower barrier to entry
  • Simpler 
  • Faster (not by a lot) 
  • Uses less memory

Differences with ASP.NET Core WebAPI

  • No support for filters. i.e. IAsyncAuthorizationFilter, IAsyncActionFilter, IAsyncExceptionFilter ....
  • No support for model binding. i.e. IModelBinderProvider, IModelBinder
  • No support for binding from forms (this will be added in the future)
  • No built-in support for validation. i.e. IModelValidator

Differences with ASP.NET CoreWebApi II

  • No built-in view rendering support
  • No support for JsonPatch
  • No support for OData
  • No support for ApiVersioning

Application objects

WebApplication &

WebApplicationBuilder

//Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
...
app.Run();
//Program.cs
var app = WebApplication.Create(args);

app.Run();

WebApplication

The web application is used to configure the HTTP pipeline (middleware), and routes.

Creating a brand application

//Program.cs
var app = WebApplication.Create(args);

app.MapGet("/", () => "Hello World!");

app.Run();

This listens to port http://localhost:5000 and https://localhost:5001 by default.

WebApplication Builder

var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
    ApplicationName = typeof(Program).Assembly.FullName,
    ContentRootPath = Directory.GetCurrentDirectory(),
    EnvironmentName = Environments.Staging
});

Console.WriteLine($"Application Name: {builder.Environment.ApplicationName}");

var app = builder.Build();

Settings

Specified via the following environment variables:

  • ASPNETCORE_ENVIRONMENT
  • ASPNETCORE_CONTENTROOT
  • ASPNETCORE_APPLICATIONNAME

Via command line arguments:

  • --environment
  • --contentRoot
  • --applicationName

Configuration

Working with SSL

//Program.cs
app.Urls.Add("https://localhost:3000");

//application.json
{
  "Kestrel": {
    "Certificates": {
      "Default": {
        "Path": "cert.pem",
        "KeyPath": "key.pem"
      }
} }}
//via configuration
var builder = WebApplication.CreateBuilder(args);

// Configure the cert and the key
builder.Configuration["Kestrel:Certificates:Default:Path"] = "cert.pem";
builder.Configuration["Kestrel:Certificates:Default:KeyPath"] = "key.pem";

var app = builder.Build();

app.Urls.Add("https://localhost:3000");

app.MapGet("/", () => "Hello World");

app.Run();

Working with SSL II

// Use the certificate APIs
using System.Security.Cryptography.X509Certificates;

var builder = WebApplication.CreateBuilder(args);

builder.WebHost.ConfigureKestrel(options =>
{
    options.ConfigureHttpsDefaults(httpsOptions =>
    {
        var certPath = Path.Combine(builder.Environment.ContentRootPath, "cert.pem");
        var keyPath = Path.Combine(builder.Environment.ContentRootPath, "key.pem");

        httpsOptions.ServerCertificate = X509Certificate2.CreateFromPemFile(certPath, 
                                         keyPath);
    });
});

var app = builder.Build();

app.Urls.Add("https://localhost:3000");

app.MapGet("/", () => "Hello World");

app.Run();

Logging & Providers

var app = WebApplication.Create(args);

app.Logger.LogInformation("The app started");

Call WebApplication.CreateBuilder, which adds the following logging providers: Console,Debug, EventSource, EventLog: Windows only

var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddEventLog();
builder.Logging.AddConsole();
//Configuration application.[environment].json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  }
}

Configuration providers

//The following sample adds the INI configuration provider:

var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddIniFile("appsettings.ini");
//Read configuration with sources: 
//appSettings.json, Environment variables, The command line

var builder = WebApplication.CreateBuilder(args);

var message = builder.Configuration["HelloKey"] ?? "Hello";
//Read the environment: IsDevelopment, IsStaging, IsProduction, IsEnvironment

var builder = WebApplication.CreateBuilder(args);
if (builder.Environment.IsDevelopment()){
    Console.WriteLine($"Running in development.");
}

DI Services

//Adding services
var builder = WebApplication.CreateBuilder(args);

// Add the memory cache services.
builder.Services.AddMemoryCache();

// Add a custom scoped service.
builder.Services.AddScoped<ITodoRepository, TodoRepository>();
builder.Services.AddTransient()<ITodoRepository, TodoRepository>();
builder.Services.AddSingleton()<ITodoRepository, TodoRepository>();
var app = builder.Build();

Service lifetime

  • Transient: created each time they're requested from the service container.
  • Scoped: created once per client request (connection).
  • Singleton: created once per Application instance.

Accessing DI Container

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<PodcastsCtx>();

var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    var dbContext = scope.ServiceProvider.GetRequiredService<PodcastsCtx>();
    dbContext.Database.Migrate();
}

app.Run();


public class PodcastsCtx: DbContext
{
        public PodcastsCtx(DbContextOptions<PodcastsCtx> options) : base(options){}
        
        public DbSet<Show> Shows { get; set; }
}

Execute migrations on application start

Autofac DI Container

var builder = WebApplication.CreateBuilder(args);

builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory());

// Register services directly with Autofac here. Don't
// call builder.Populate(), that happens in AutofacServiceProviderFactory.
builder.Host.ConfigureContainer<ContainerBuilder>(builder => 
			builder.RegisterModule(new MyApplicationModule()));

var app = builder.Build();

WebApplication

var app = WebApplication.Create(args);

app.MapGet("/", () => "Hello World!");

app.Run("http://localhost:3000");
//multiple ports
var app = WebApplication.Create(args);

app.Urls.Add("http://localhost:3000");
app.Urls.Add("http://localhost:4000");

app.MapGet("/", () => "Hello World");

app.Run();
dotnet run --urls="https://localhost:7777"
var app = WebApplication.Create(args);

var port = Environment.GetEnvironmentVariable("PORT") ?? "3000";

app.Run($"http://*:{port}");
ASPNETCORE_URLS=http://localhost:3000;https://localhost:5000

Working with ports

Adding middleware

Any existing ASP.NET Core middleware can be configured on the WebApplication:

var app = WebApplication.Create(args);

// Setup the file server to serve static files
app.UseFileServer();

app.Run();

Available middlewares

Request handling

Routing

A configured WebApplication supports Map{Verb} and MapMethods:

var app = WebApplication.Create(args);

app.MapGet("/", () => "This is a GET");
app.MapPost("/", () => "This is a POST");
app.MapPut("/", () => "This is a PUT");
app.MapDelete("/", () => "This is a DELETE");

app.MapMethods("/options-or-head", new[] { "OPTIONS", "HEAD" }, 
                          () => "This is an options or head request ");

app.Run();

Route Handlers

Route handlers are methods that execute when the route matches.

 

Route handlers can be:

  • a lambda expression
  • a local function
  • an instance method
  • a static method

Lambda expression

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/inline", () => "This is an inline lambda");

var handler = () => "This is a lambda variable";

app.MapGet("/", handler);

app.Run();

Local function

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

string LocalFunction() => "This is local function";

app.MapGet("/", LocalFunction);

app.Run();

Instance method

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var handler = new HelloHandler();

app.MapGet("/", handler.Hello);

app.Run();

class HelloHandler
{
    public string Hello()
    {
        return "Hello Instance method";
    }
}

Static method

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", HelloHandler.Hello);

app.Run();

class HelloHandler
{
    public static string Hello()
    {
        return "Hello static method";
    }
}

Named route & link generator

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/hello", () => "Hello named route")
   .WithName("hi");

app.MapGet("/", (LinkGenerator linker) => 
        $"The link to the hello route is {linker.GetPathByName("hi", values: null)}");

The preceding code displays "The link to the hello route is /hello" from the / endpoint.

string Hi() => "Hello there";
app.MapGet("/hello", Hi);

app.MapGet("/", (LinkGenerator linker) => 
        $"The link to the hello route is {linker.GetPathByName("Hi", values: null)}");

Route names are inferred from method names if specified.

Route names are case sensitive!

Route Parameters

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/users/{userId}/books/{bookId}", 
    (int userId, int bookId) => $"The user id is {userId} and book id is {bookId}");

app.Run();

Route parameters can be captured as part of the route pattern definition.

Wildcard and catch all routes

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/posts/{*rest}", (string rest) => $"Routing to {rest}");

app.Run();

The following catch all route returns Routing to hello from the `/posts/hello' endpoint

Route constraints

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/todos/{id:int}", (int id) => db.Todos.Find(id));
app.MapGet("/todos/{text}", (string text) => db.Todos.Where(t => t.Text.Contains(text));
app.MapGet("/posts/{slug:regex(^[a-z0-9_-]+$)}", (string slug) => $"Post {slug}");

app.Run();

Parameter Binding

NOTE: Binding from forms is not natively supported in .NET 6.

Parameter binding is the process of converting request data into strongly typed parameters that are expressed by route handlers.

Supported binding sources:

  • Route values

  • Query string

  • Header

  • Body (as JSON)

  • Services provided by dependency injection

GET, HEAD, OPTIONS, DELETE

The HTTP methods GET, HEAD, OPTIONS, and DELETE don't implicitly bind from body. Bind explicitly with [FromBody]

NOTE: To support the case of GET with a body, directly read it from the HttpRequest.

var builder = WebApplication.CreateBuilder(args);

// Added as service
builder.Services.AddSingleton<Service>();

var app = builder.Build();

app.MapGet("/{id}", (int id, int page, Service service) => { });

class Service { }

id:route value, page: query string, service: Provided by dependency injection

POST, PUT

var builder = WebApplication.CreateBuilder(args);

// Added as service
builder.Services.AddSingleton<Service>();

var app = builder.Build();

app.MapPost("/", (Person person, Service service) => { });

class Service { }

record Person(string Name, int Age);

person: body (as JSON), service: Provided by dependency injection

Explicit Parameter Binding

using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

// Added as service
builder.Services.AddSingleton<Service>();

var app = builder.Build();


app.MapGet("/{id}", ([FromRoute] int id,
                     [FromQuery(Name = "p")] int page,
                     [FromServices] Service service,
                     [FromHeader(Name = "Content-Type")] string contentType) 
                     => {});

class Service { }

record Person(string Name, int Age);

Binding precedence

1. Explicit attribute defined on parameter ([From*] attributes) in the following order:

2. If the parameter type is a service provided by dependency injection, it will use that as the source.

  • Route values ([FromRoute])

  • Query string ([FromQuery])

  • Header ([FromHeader])

  • Body ([FromBody])

  • Service ([FromServices])

3. The parameter is from the body.

Special types

The following types are bound without explicit attributes:

  • HttpContext : The context which holds all the information about the current HTTP request or response.

  • HttpRequest : The HTTP request

  • HttpResponse : The HTTP response

  • CancellationToken : The cancellation token associated with the current http request.

  • ClaimsPrincipal : The user associated with the request (HttpContext.User)

Responses

//Example: string return values
app.MapGet("/hello", () => "Hello World");

//Example: JSON return values
app.MapGet("/hello", () => new { Message = "Hello World" });

//Example: IResult return values
app.MapGet("/hello", () => Results.Ok(new { Message = "Hello World" }));

//The following example uses the built-in result types to customize the response:
app.MapGet("/todos/{id}", (int id, TodoDb db) => 
    db.Todos.Find(id) is Todo todo 
        ? Results.Ok(todo)
        : Results.NotFound()
);
//JSON
app.MapGet("/hello", () => Results.Json(new { Message = "Hello World" }));

//Custom Status Code
app.MapGet("/405", () => Results.StatusCode(405));

//Text
app.MapGet("/text", () => Results.Text("This is some text"));

//Stream
var proxyClient = new HttpClient();
app.MapGet("/pokemon", async () => 
{
    var stream = await proxyClient.GetStreamAsync("http://myurl/pokedex.json")
    // Proxy the response as JSON
    return Results.Stream(stream, "application/json");
});

//Redirect
app.MapGet("/old-path", () => Results.Redirect("/new-path"));

//File
app.MapGet("/download", () => Results.File("foo.text"));

More responses

Authentication & Authorization

Configuration

//Builder
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication();
builder.Services.AddAuthorization();

//Application
var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

Policies

Routes can be protected using authorization policies.

These can be declared via the [Authorize] attribute or by using the .RequireAuthorization method.


app.MapGet("/auth", [Authorize] () => "This endpoint requires authorization");

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthorization(
	o => o.AddPolicy("AdminsOnly", b => b.RequireClaim("admin", "true"));

var app = builder.Build();

app.UseAuthorization();

app.MapGet("/admin", () => "This endpoint is for admins only")
   .RequireAuthorization("AdminsOnly");

Allowing unauthenticated users


app.MapGet("/login", [AllowAnonymous] () => "This endpoint is for admins only");

app.MapGet("/login", () => "This endpoint is for admins only").AllowAnonymous();

JsonWebToken Bearer


builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer();
        
builder.Services.AddAuthorization(options =>{
    options.FallbackPolicy =  new AuthorizationPolicyBuilder()
    .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme)
    .RequireAuthenticatedUser()
    .Build();
});

app.MapGet("/login", () => "This endpoint is for admins only").AllowAnonymous();

Basic Authentication


builder.Services.AddAuthentication("BasicAuthentication")
    .AddScheme<AuthenticationSchemeOptions, BasicAuthenticationHandler>
    ("BasicAuthentication", null);

  public class BasicAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
    {
        public BasicAuthenticationHandler( 
            IOptionsMonitor<AuthenticationSchemeOptions> options,ILoggerFactory logger,
            UrlEncoder encoder, ISystemClock clock,  UserService userService ){}

        protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
        {
            // skip authentication if endpoint has [AllowAnonymous] attribute
            var endpoint = Context.GetEndpoint();
            if (endpoint?.Metadata?.GetMetadata<IAllowAnonymous>() != null)
                return AuthenticateResult.NoResult();
            
            if (!Request.Headers.ContainsKey("Authorization"))
                return AuthenticateResult.Fail("Missing Authorization Header");

            // ......
            return AuthenticateResult.Success();
        }}

CORS

Policies

Routes can be CORS enabled using CORS policies.

CORS can be declared via the [EnableCors] attribute or by using the .RequireCors method.

Policies

const string MyAllowSpecificOrigins = "_allowedOrigins";

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddCors(options =>
{
    options.AddPolicy(name: MyAllowSpecificOrigins,
                      builder =>
                      {
                          builder.WithOrigins("http://example.com",
                                              "http://www.contoso.com");
                      });
});

var app = builder.Build();

app.UseCors();
app.MapGet("/cors", [EnableCors(MyAllowSpecificOrigins)] () => 
                           "This endpoint allows cross origin requests!");
app.MapGet("/cors2", () => "This endpoint allows cross origin requests!")
                     .RequireCors(MyAllowSpecificOrigins);

Open API

Open API specification

Configuration

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new() { Title = builder.Environment.ApplicationName,
                               Version = "v1" });
});

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json",
                                    $"{builder.Environment.ApplicationName} v1"));
}

An application can describe the OpenAPI specification for route handlers using Swashbuckle.

Exclude Open API endpoints

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.MapGet("/swag", () => "Hello Swagger!");
app.MapGet("/skipme", () => "Skipping Swagger.")
                    .ExcludeFromDescription();

app.Run();

Describe response types

app.MapGet("/api/todoitems/{id}", async (int id, TodoDb db) =>
         await db.Todos.FindAsync(id) 
         is Todo todo
         ? Results.Ok(todo) 
         : Results.NotFound())
   .Produces<Todo>(StatusCodes.Status200OK)
   .Produces(StatusCodes.Status404NotFound);

Basic Authentication

//config
builder.Services.AddSwaggerGen(c =>
{
    c .AddSecurityDefinition("basic", new OpenApiSecurityScheme {  
        Name = "Authorization",  
        Type = SecuritySchemeType.Http,  
        Scheme = "basic",  
        In = ParameterLocation.Header,  
        Description = "Basic Authorization header using the Bearer scheme."  
    });  
    c.AddSecurityRequirement(new OpenApiSecurityRequirement  { {  
            new OpenApiSecurityScheme  {  
                Reference = new OpenApiReference   {  
                    Type = ReferenceType.SecurityScheme,  
                    Id = "basic"  
                }  
            }, Array.Empty<string>() }  
    });
});

Bearer Authentication

c .AddSecurityDefinition("Bearer", new OpenApiSecurityScheme {  
        Description="JWT authorization using bearer scheme",
        Name = "Authorization",  
        Type = SecuritySchemeType.ApiKey,  
        In = ParameterLocation.Header,  
    });  
    c.AddSecurityRequirement(new OpenApiSecurityRequirement  {{  
            new OpenApiSecurityScheme {  
                Reference = new OpenApiReference {  
                    Type = ReferenceType.SecurityScheme,  
                    Id = "Bearer"  
                }  
            },Array.Empty<string>()}});

Exception handling

Exception Middleware

app.UseExceptionHandler((errorApp) =>
    {
        errorApp.Run(async (context) =>
        {
            var exceptionHandlerFeature =
                context.Features.Get<IExceptionHandlerFeature>();

            if (exceptionHandlerFeature?.Error != null)
                app.Logger.LogError(exceptionHandlerFeature?.Error,
                    "Global error logged again with some custom data!");

            context.Response.StatusCode = 500;
            context.Response.ContentType = "application/json";

            var errorMessage = new { Error = "Internal Server error! " };
            await context.Response.WriteAsync(JsonSerializer.Serialize(errorMessage));
        });
    });

Exception Middleware


if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}
else
{
    //global error handler
    app.UseExceptionHandler((errorApp) =>
    {
    	// Handling code from prev slide ..
     });   
}

Exception Page

Request data validation

Mini Validation

public class Todo
{
    public int? Id { get; set; }
    [Required]
    public string? Title { get; set; }
}
        if (!MiniValidator.TryValidate(todo, out var errors))
        {
            return Results.BadRequest(
                errors.Keys.SelectMany(x => 
                	errors[x].Select(y => new { Property = x, Error = y }))
            );
        }
        

A minimalistic validation library built atop the existing features in .NET's System.ComponentModel.DataAnnotations namespace.

Fluent Validation

public class TodoValidator : AbstractValidator<Todo>
{
    public TodoValidator() =>
    	RuleFor(x => x.Title).NotNull().WithMessage("Title required");
}
app.MapPut("/api/todos/{id}", IValidator<Todo> validator, ....) => {
		//mapping and other code	
        var validationResult = validator.Validate(todo);
        if (!validationResult.IsValid)
        {
            return Results.BadRequest(
                validationResult.Errors
                  .Select(x => 
                    new { Property = x.PropertyName, Error = x.ErrorMessage }));
        }});

Install-Package FluentValidation.AspNetCore -Version

A popular .NET library for building strongly-typed validation rules.

builder.Services.AddValidatorsFromAssemblyContaining<Todo>
						(lifetime: ServiceLifetime.Scoped);

Testing (integration)

Setup

public class TodosApplication : WebApplicationFactory<Program>{
        protected override IHost CreateHost(IHostBuilder builder){
            
            builder.ConfigureServices(services => {
                // services.RemoveAll(typeof(DbContextOptions<TodoDbContext>));
                // services.AddDbContext<TodoDbContext>(options =>
                //     options.UseInMemoryDatabase("Testing", root));
            });

            return base.CreateHost(builder);
        }
    }
private readonly HttpClient _client;

    public ApiTests()
    {
        var application = new TodosApplication();
        _client = application.CreateClient();
        _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic",
            Convert.ToBase64String(Encoding.UTF8.GetBytes("username:password")));
    }

Install-Package Microsoft.NET.Test.Sdk

Install-Package Microsoft.AspNetCore.Mvc.Testing

//GET
[Fact]
public async Task Can_call_get_todos()    {
	var todos = await _client.GetFromJsonAsync<List<TodoDto>>("/api/todos");
	Assert.True(todos is List<TodoDto>);
}
  //POST & PUT
  [Fact]
  public async Task Can_post_todo()    {
  	var response = await _client.PostAsJsonAsync("/api/todos",
    	new TodoDto(null, Title: "I want to do this thing tomorrow"));

	Assert.Equal(HttpStatusCode.Created, response.StatusCode);

	var todos = await _client.GetFromJsonAsync<List<Todo>>("/api/todos");

	var todo = todos.Last();
	Assert.Equal("I want to do this thing tomorrow", todo.Title);
	Assert.False(todo.IsCompleted);
}
//Delete
[Fact]
public async Task Can_call_get_todos()    {
	var id = 23;
	var response = await _client.DeleteAsync($"/api/todos/{id}");
	Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
}

Agenda recap

  • Integration testing
  • Application objects
  • Request handling
  • Auth & Auth
  • CORS
  • Open API
  • Exception handling
  • Request data validation

Minimal API Workshop

Thank you!

@imhotepp

2022