Skip to main content

Middleware & Pipeline

The Ampra Web API uses a carefully ordered middleware pipeline and action filters to handle cross-cutting concerns: CORS, exception handling, authentication, authorization, and request validation.


HTTP Pipeline Order

The pipeline is configured in Startup.Configure and processes requests in this order:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (!env.IsDevelopment())
app.UseHsts();

app.UseCors(PolicyNames.CorsPolicy);
app.UseMiddleware<Middleware.GlobalExceptionMiddleware>();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();

app.UseEndpoints(endpoints =>
{
if (env.IsDevelopment())
endpoints.MapOpenApi();

endpoints.MapControllers();
});
}

GlobalExceptionMiddleware

Location: Ampra.Web/Middleware/GlobalExceptionMiddleware.cs

Catches all unhandled exceptions and maps them to appropriate HTTP status codes with consistent JSON error responses. Every exception type produces a { "message": "..." } JSON body.

Exception → HTTP Status Mapping

Exception TypeHTTP StatusResponse Message
UnauthorizedAccessException403 Forbidden"Access denied"
NotFoundException404 Not FoundException message (custom)
ArgumentException400 Bad Request"Invalid request"
InvalidOperationException400 Bad Request"This operation could not be completed"
Exception (catch-all)500 Internal Server Error"An unexpected error occurred"

Implementation

public class GlobalExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<GlobalExceptionMiddleware> _logger;

public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (UnauthorizedAccessException)
{
if (context.Response.HasStarted) throw;
context.Response.StatusCode = (int)HttpStatusCode.Forbidden;
context.Response.ContentType = HttpContentTypes.Json;
await context.Response.WriteAsync(
JsonSerializer.Serialize(new { message = "Access denied" }));
}
catch (NotFoundException ex)
{
if (context.Response.HasStarted) throw;
context.Response.StatusCode = (int)HttpStatusCode.NotFound;
context.Response.ContentType = HttpContentTypes.Json;
await context.Response.WriteAsync(
JsonSerializer.Serialize(new { message = ex.Message }));
}
// ... ArgumentException → 400, InvalidOperationException → 400
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception on {Method} {Path}",
context.Request.Method, context.Request.Path);
if (context.Response.HasStarted) throw;
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
context.Response.ContentType = HttpContentTypes.Json;
await context.Response.WriteAsync(
JsonSerializer.Serialize(new { message = "An unexpected error occurred" }));
}
}
}

Key behaviours:

  • Checks context.Response.HasStarted before writing — if headers are already sent, the original exception is re-thrown
  • Only the catch-all Exception handler logs the error (specific types are expected business exceptions)
  • Error messages are intentionally generic for security — internal details are never exposed to clients

ValidationFilter

Location: Ampra.Web/Filters/ValidationFilter.cs

An IAsyncActionFilter that automatically validates incoming request objects using FluentValidation. If a validator exists for a parameter type, it's resolved from DI and executed before the action runs.

How It Works

Implementation

public class ValidationFilter : IAsyncActionFilter
{
private readonly IServiceProvider _serviceProvider;

public async Task OnActionExecutionAsync(
ActionExecutingContext context, ActionExecutionDelegate next)
{
foreach (var argument in context.ActionArguments.Values)
{
if (argument is null) continue;

var argumentType = argument.GetType();
var validatorType = typeof(IValidator<>).MakeGenericType(argumentType);
var validator = _serviceProvider.GetService(validatorType) as IValidator;

if (validator is null) continue;

var validationContext = new ValidationContext<object>(argument);
var result = await validator.ValidateAsync(validationContext);

if (!result.IsValid)
{
var errors = result.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(
g => g.Key,
g => g.Select(e => e.ErrorMessage).ToArray()
);

context.Result = new BadRequestObjectResult(
new ValidationProblemDetails(errors));
return; // Short-circuit — action never executes
}
}

await next(); // All arguments valid — proceed to action
}
}

Registration

The filter is registered globally as a service filter in Startup.ConfigureServices:

services.AddControllers(options =>
{
options.Filters.Add<ValidationFilter>();
});

// FluentValidation validators are auto-registered from the Application assembly
services.AddValidatorsFromAssemblyContaining
<Ampra.Application.Validators.LoginRequestValidator>();

Error Response Format

When validation fails, the response follows the RFC 7807 Problem Details standard:

{
"type": "https://tools.ietf.org/html/rfc7807",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Email": ["'Email' is not a valid email address."],
"Password": [
"'Password' must be at least 8 characters.",
"'Password' must contain at least one uppercase letter."
]
}
}

AuthenticatedControllerBase

Location: Ampra.Web/Controllers/AuthenticatedControllerBase.cs

Abstract base class that all authenticated controllers inherit from. Provides a single helper to extract the current user's ID from the JWT/cookie claims.

public abstract class AuthenticatedControllerBase : ControllerBase
{
protected string GetUserId() =>
User.FindFirstValue(ClaimTypes.NameIdentifier)
?? throw new UnauthorizedAccessException("User not authenticated");
}

Every controller that requires authentication inherits from this base and calls GetUserId():

[ApiController]
[Route("api/sun-sources")]
[Authorize(Policy = Policies.Omni)]
public class SunSourcesController : AuthenticatedControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] Guid? powerGroupId)
{
var userId = GetUserId();
var sources = await _sunSourceService.GetUserSunSourcesAsync(userId, powerGroupId);
return Ok(sources);
}
}

Authorization

ASP.NET Identity cookie authentication with the following settings:

services.ConfigureApplicationCookie(options =>
{
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Lax;
options.SlidingExpiration = true;
options.ExpireTimeSpan = TimeSpan.FromDays(7);

// Return 401/403 JSON instead of redirecting to login page
options.Events.OnRedirectToLogin = ctx => { ctx.Response.StatusCode = 401; /* ... */ };
options.Events.OnRedirectToAccessDenied = ctx => { ctx.Response.StatusCode = 403; /* ... */ };
});

Authorization Policies

services.AddAuthorization(options =>
{
options.AddPolicy(Policies.Omni, policy =>
policy.RequireRole(Roles.Overseer, Roles.Admin, Roles.User));
});

The Omni policy requires the user to have any of the three roles — Overseer, Admin, or User. Admin-only endpoints use [Authorize(Roles = "Overseer,Admin")] directly on the controller.

Identity Configuration

services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequiredLength = 8;

options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
options.Lockout.MaxFailedAccessAttempts = 5;

options.User.RequireUniqueEmail = true;
});

CORS Configuration

services.AddCors(options =>
{
options.AddPolicy(PolicyNames.CorsPolicy, builder =>
{
var origins = Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>();
builder.WithOrigins(origins ?? Array.Empty<string>())
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});

Origins are configured per environment in appsettings.json / appsettings.Development.json and support .AllowCredentials() for cookie-based auth.