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 Type | HTTP Status | Response Message |
|---|---|---|
UnauthorizedAccessException | 403 Forbidden | "Access denied" |
NotFoundException | 404 Not Found | Exception message (custom) |
ArgumentException | 400 Bad Request | "Invalid request" |
InvalidOperationException | 400 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.HasStartedbefore writing — if headers are already sent, the original exception is re-thrown - Only the catch-all
Exceptionhandler 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
Cookie Authentication
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.