Уявіть, що ви будуєте API з 50 endpoints. Кожен endpoint потребує:
Якщо писати цю логіку в кожному контролері, ви отримаєте:
[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(int id)
{
// 1. Логування
_logger.LogInformation("Getting product {Id}", id);
// 2. Валідація
if (id <= 0) return BadRequest("Invalid ID");
// 3. Перевірка API-ключа
if (!ValidateApiKey()) return Unauthorized();
// 4. Вимірювання часу
var stopwatch = Stopwatch.StartNew();
// 5. Бізнес-логіка (нарешті!)
var product = await _db.Products.FindAsync(id);
// 6. Логування результату
stopwatch.Stop();
_logger.LogInformation("Request completed in {Ms}ms", stopwatch.ElapsedMilliseconds);
// 7. Обгортання відповіді
return Ok(new { success = true, data = product });
}
Проблеми цього підходу:
Рішення — Filters (фільтри) — механізм ASP.NET Core для реалізації cross-cutting concerns (наскрізних аспектів) без дублювання коду. Фільтри виконуються автоматично до або після action методів, дозволяючи централізувати логіку.
Ми побудуємо E-commerce API з професійною системою фільтрів:
1. ApiKeyAuthFilter — авторизація через API-ключ:
GET /api/products
X-Api-Key: sk_live_abc123xyz
→ 200 OK (якщо ключ валідний)
→ 401 Unauthorized (якщо ключ невалідний)
2. RequestValidationFilter — централізована валідація:
POST /api/products
{ "name": "", "price": -10 }
→ 400 Bad Request з деталями помилок
3. ResponseWrapperFilter — envelope pattern:
{
"success": true,
"data": { "id": 1, "name": "Laptop" },
"meta": {
"timestamp": "2024-01-15T10:30:00Z",
"version": "1.0"
}
}
4. CorrelationIdFilter — traceability:
Response Headers:
X-Correlation-ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890
5. PerformanceMonitoringFilter — метрики:
[INFO] GET /api/products/1 completed in 45ms
До кінця статті ви зможете:
Фільтри виконуються у чіткому порядку навколо action методу:
| Тип фільтру | Інтерфейс | Коли виконується | Використання для API |
|---|---|---|---|
| Authorization | IAuthorizationFilter | Перший у pipeline | API key validation, JWT verification |
| Resource | IResourceFilter | До model binding | Caching, rate limiting |
| Action | IActionFilter | До/після action | Валідація DTO, логування |
| Exception | IExceptionFilter | При винятку | Перетворення у ProblemDetails |
| Result | IResultFilter | До/після result | Response wrapping, headers |
Кожен тип фільтру має дві версії:
Синхронний:
public class MyActionFilter : IActionFilter
{
public void OnActionExecuting(ActionExecutingContext context)
{
// До виконання action
}
public void OnActionExecuted(ActionExecutedContext context)
{
// Після виконання action
}
}
Асинхронний (рекомендовано для API):
public class MyAsyncActionFilter : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
// До виконання action
var resultContext = await next(); // Виконуємо action
// Після виконання action
}
}
IAsyncActionFilter, синхронні методи IActionFilterігноруються.Створіть файл Models/Product.cs:
using System.ComponentModel.DataAnnotations;
namespace EcommerceFiltersApi.Models;
public class Product
{
public int Id { get; set; }
[Required]
[MaxLength(200)]
public required string Name { get; set; }
[Range(0.01, 1_000_000)]
public decimal Price { get; set; }
[Range(0, int.MaxValue)]
public int Stock { get; set; }
public bool IsActive { get; set; } = true;
}
public record CreateProductDto
{
[Required(ErrorMessage = "Product name is required")]
[MaxLength(200, ErrorMessage = "Name cannot exceed 200 characters")]
public required string Name { get; init; }
[Range(0.01, 1_000_000, ErrorMessage = "Price must be between 0.01 and 1,000,000")]
public decimal Price { get; init; }
[Range(0, int.MaxValue, ErrorMessage = "Stock cannot be negative")]
public int Stock { get; init; }
}
Створіть файл Filters/ApiKeyAuthFilter.cs:
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace EcommerceFiltersApi.Filters;
public class ApiKeyAuthFilter : IAsyncAuthorizationFilter
{
private const string ApiKeyHeaderName = "X-Api-Key";
private readonly IConfiguration _configuration;
private readonly ILogger<ApiKeyAuthFilter> _logger;
public ApiKeyAuthFilter(IConfiguration configuration, ILogger<ApiKeyAuthFilter> logger)
{
_configuration = configuration;
_logger = logger;
}
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
{
// Перевіряємо наявність API-ключа у заголовку
if (!context.HttpContext.Request.Headers.TryGetValue(ApiKeyHeaderName, out var extractedApiKey))
{
_logger.LogWarning("API key is missing in request to {Path}", context.HttpContext.Request.Path);
context.Result = new UnauthorizedObjectResult(new ProblemDetails
{
Status = StatusCodes.Status401Unauthorized,
Title = "API Key Required",
Detail = $"API key must be provided in {ApiKeyHeaderName} header",
Instance = context.HttpContext.Request.Path
});
return;
}
// Отримуємо валідні ключі з конфігурації
var validApiKeys = _configuration.GetSection("ApiKeys").Get<string[]>() ?? Array.Empty<string>();
// Перевіряємо валідність ключа
if (!validApiKeys.Contains(extractedApiKey.ToString()))
{
_logger.LogWarning("Invalid API key attempted: {ApiKey}", extractedApiKey);
context.Result = new UnauthorizedObjectResult(new ProblemDetails
{
Status = StatusCodes.Status401Unauthorized,
Title = "Invalid API Key",
Detail = "The provided API key is not valid",
Instance = context.HttpContext.Request.Path
});
return;
}
_logger.LogInformation("API key validated successfully for {Path}", context.HttpContext.Request.Path);
// Додаємо інформацію про API key до HttpContext для використання в контролерах
context.HttpContext.Items["ApiKey"] = extractedApiKey.ToString();
await Task.CompletedTask;
}
}
Декомпозиція:
IAsyncAuthorizationFilter — виконується першим у pipelineX-Api-Keycontext.Result — якщо встановлено, action не виконуєтьсяHttpContext.Items — зберігаємо ключ для використання в контролерахСтворіть файл Filters/RequestValidationFilter.cs:
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace EcommerceFiltersApi.Filters;
public class RequestValidationFilter : IAsyncActionFilter
{
private readonly ILogger<RequestValidationFilter> _logger;
public RequestValidationFilter(ILogger<RequestValidationFilter> logger)
{
_logger = logger;
}
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
// Перевіряємо ModelState (DataAnnotations валідація)
if (!context.ModelState.IsValid)
{
_logger.LogWarning(
"Validation failed for {Action}. Errors: {Errors}",
context.ActionDescriptor.DisplayName,
string.Join(", ", context.ModelState.Values
.SelectMany(v => v.Errors)
.Select(e => e.ErrorMessage)));
var errors = context.ModelState
.Where(x => x.Value?.Errors.Count > 0)
.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value!.Errors.Select(e => e.ErrorMessage).ToArray()
);
var problemDetails = new ValidationProblemDetails(errors)
{
Status = StatusCodes.Status400BadRequest,
Title = "One or more validation errors occurred",
Instance = context.HttpContext.Request.Path
};
context.Result = new BadRequestObjectResult(problemDetails);
return; // Не викликаємо next() - action не виконується
}
// Кастомна валідація: перевірка бізнес-правил
foreach (var argument in context.ActionArguments.Values)
{
if (argument is CreateProductDto dto)
{
// Приклад бізнес-правила: ціна має бути кратною 0.01
if (dto.Price % 0.01m != 0)
{
context.Result = new BadRequestObjectResult(new ProblemDetails
{
Status = StatusCodes.Status400BadRequest,
Title = "Invalid Price Format",
Detail = "Price must have at most 2 decimal places",
Instance = context.HttpContext.Request.Path
});
return;
}
}
}
_logger.LogInformation("Validation passed for {Action}", context.ActionDescriptor.DisplayName);
// Валідація пройшла - виконуємо action
await next();
}
}
Ключові моменти:
IAsyncActionFilter — виконується безпосередньо до/після actionModelState.IsValid — перевірка DataAnnotationscontext.Result — якщо встановлено, action не виконуєтьсяawait next() — виконує action та наступні фільтриСтворіть файл Filters/ResponseWrapperFilter.cs:
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace EcommerceFiltersApi.Filters;
public class ResponseWrapperFilter : IAsyncResultFilter
{
public async Task OnResultExecutionAsync(
ResultExecutingContext context,
ResultExecutionDelegate next)
{
// Обгортаємо тільки успішні відповіді (200-299)
if (context.Result is ObjectResult objectResult &&
objectResult.StatusCode >= 200 &&
objectResult.StatusCode < 300)
{
var wrappedResponse = new ApiResponse<object>
{
Success = true,
Data = objectResult.Value,
Meta = new ResponseMeta
{
Timestamp = DateTime.UtcNow,
Version = "1.0",
Path = context.HttpContext.Request.Path
}
};
context.Result = new ObjectResult(wrappedResponse)
{
StatusCode = objectResult.StatusCode
};
}
await next();
}
}
// DTO для обгортки
public record ApiResponse<T>
{
public bool Success { get; init; }
public T? Data { get; init; }
public ResponseMeta? Meta { get; init; }
}
public record ResponseMeta
{
public DateTime Timestamp { get; init; }
public string Version { get; init; } = "1.0";
public string? Path { get; init; }
}
Результат:
{
"success": true,
"data": {
"id": 1,
"name": "Laptop",
"price": 1499.99
},
"meta": {
"timestamp": "2024-01-15T10:30:00Z",
"version": "1.0",
"path": "/api/products/1"
}
}
Створіть файл Filters/CorrelationIdFilter.cs:
using Microsoft.AspNetCore.Mvc.Filters;
using System.Diagnostics;
namespace EcommerceFiltersApi.Filters;
public class CorrelationIdFilter : IAsyncActionFilter
{
private const string CorrelationIdHeader = "X-Correlation-ID";
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
// Отримуємо або генеруємо Correlation ID
var correlationId = context.HttpContext.Request.Headers[CorrelationIdHeader]
.FirstOrDefault() ?? Guid.NewGuid().ToString();
// Додаємо до response headers
context.HttpContext.Response.Headers.Append(CorrelationIdHeader, correlationId);
// Додаємо до Activity для distributed tracing
Activity.Current?.SetTag("correlation.id", correlationId);
// Додаємо до HttpContext для доступу в контролерах
context.HttpContext.Items["CorrelationId"] = correlationId;
await next();
}
}
Створіть файл Filters/PerformanceMonitoringFilter.cs:
using Microsoft.AspNetCore.Mvc.Filters;
using System.Diagnostics;
namespace EcommerceFiltersApi.Filters;
public class PerformanceMonitoringFilter : IAsyncActionFilter
{
private readonly ILogger<PerformanceMonitoringFilter> _logger;
private readonly int _warningThresholdMs;
public PerformanceMonitoringFilter(
ILogger<PerformanceMonitoringFilter> logger,
IConfiguration configuration)
{
_logger = logger;
_warningThresholdMs = configuration.GetValue<int>("Performance:WarningThresholdMs", 1000);
}
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
var stopwatch = Stopwatch.StartNew();
var actionName = context.ActionDescriptor.DisplayName;
var method = context.HttpContext.Request.Method;
var path = context.HttpContext.Request.Path;
_logger.LogInformation(
"Starting {Method} {Path} ({Action})",
method,
path,
actionName);
// Виконуємо action
var resultContext = await next();
stopwatch.Stop();
var elapsedMs = stopwatch.ElapsedMilliseconds;
// Додаємо час виконання до response headers
context.HttpContext.Response.Headers.Append("X-Response-Time-Ms", elapsedMs.ToString());
// Логуємо з різними рівнями залежно від часу виконання
if (elapsedMs > _warningThresholdMs)
{
_logger.LogWarning(
"SLOW REQUEST: {Method} {Path} completed in {ElapsedMs}ms (threshold: {Threshold}ms)",
method,
path,
elapsedMs,
_warningThresholdMs);
}
else
{
_logger.LogInformation(
"{Method} {Path} completed in {ElapsedMs}ms",
method,
path,
elapsedMs);
}
// Додаємо метрики до HttpContext для можливого використання в інших фільтрах
context.HttpContext.Items["PerformanceMetrics"] = new
{
ElapsedMilliseconds = elapsedMs,
ActionName = actionName,
IsSlowRequest = elapsedMs > _warningThresholdMs
};
}
}
Результат у логах:
[INFO] Starting GET /api/products/1 (ProductsController.GetById)
[INFO] GET /api/products/1 completed in 45ms
[WARN] SLOW REQUEST: GET /api/products/search completed in 1250ms (threshold: 1000ms)
Є три способи реєстрації фільтрів:
// Program.cs
builder.Services.AddControllers(options =>
{
// Фільтри виконуються у порядку додавання
options.Filters.Add<CorrelationIdFilter>();
options.Filters.Add<PerformanceMonitoringFilter>();
options.Filters.Add<RequestValidationFilter>();
options.Filters.Add<ResponseWrapperFilter>();
});
// Реєструємо фільтри у DI для можливості ін'єкції залежностей
builder.Services.AddScoped<ApiKeyAuthFilter>();
builder.Services.AddScoped<RequestValidationFilter>();
builder.Services.AddScoped<ResponseWrapperFilter>();
builder.Services.AddScoped<CorrelationIdFilter>();
builder.Services.AddScoped<PerformanceMonitoringFilter>();
[ApiController]
[Route("api/[controller]")]
[ServiceFilter(typeof(ApiKeyAuthFilter))] // Для всіх методів контролера
public class ProductsController : ControllerBase
{
// ...
}
[HttpPost]
[ServiceFilter(typeof(RequestValidationFilter))] // Тільки для цього методу
public async Task<IActionResult> Create(CreateProductDto dto)
{
// ...
}
[ServiceFilter(typeof(MyFilter))] — фільтр береться з DI (потрібна реєстрація)[TypeFilter(typeof(MyFilter))] — фільтр створюється щоразу (без DI)ServiceFilter для фільтрів з залежностями (ILogger, IConfiguration).using Microsoft.EntityFrameworkCore;
using EcommerceFiltersApi.Data;
using EcommerceFiltersApi.Filters;
var builder = WebApplication.CreateBuilder(args);
// Реєстрація DbContext
builder.Services.AddDbContext<ProductDbContext>(options =>
options.UseInMemoryDatabase("ProductsDb"));
// Реєстрація фільтрів у DI
builder.Services.AddScoped<ApiKeyAuthFilter>();
builder.Services.AddScoped<RequestValidationFilter>();
builder.Services.AddScoped<ResponseWrapperFilter>();
builder.Services.AddScoped<CorrelationIdFilter>();
builder.Services.AddScoped<PerformanceMonitoringFilter>();
// Налаштування Controllers з глобальними фільтрами
builder.Services.AddControllers(options =>
{
// Порядок важливий!
options.Filters.Add<CorrelationIdFilter>(); // 1. Додаємо Correlation ID
options.Filters.Add<PerformanceMonitoringFilter>(); // 2. Починаємо вимірювання
options.Filters.Add<RequestValidationFilter>(); // 3. Валідуємо запит
options.Filters.Add<ResponseWrapperFilter>(); // 4. Обгортаємо відповідь
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Ініціалізація бази даних
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ProductDbContext>();
db.Database.EnsureCreated();
}
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
{
"ApiKeys": [
"sk_test_abc123xyz",
"sk_live_def456uvw"
],
"Performance": {
"WarningThresholdMs": 1000
},
"Logging": {
"LogLevel": {
"Default": "Information",
"EcommerceFiltersApi.Filters": "Information"
}
}
}
Створіть файл Controllers/ProductsController.cs:
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using EcommerceFiltersApi.Data;
using EcommerceFiltersApi.Models;
using EcommerceFiltersApi.Filters;
namespace EcommerceFiltersApi.Controllers;
[ApiController]
[Route("api/[controller]")]
[ServiceFilter(typeof(ApiKeyAuthFilter))] // API key для всього контролера
public class ProductsController : ControllerBase
{
private readonly ProductDbContext _db;
private readonly ILogger<ProductsController> _logger;
public ProductsController(ProductDbContext db, ILogger<ProductsController> logger)
{
_db = db;
_logger = logger;
}
/// <summary>
/// Отримати всі продукти
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<IEnumerable<Product>>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<Product>>> GetAll()
{
// Отримуємо Correlation ID з HttpContext (додано фільтром)
var correlationId = HttpContext.Items["CorrelationId"]?.ToString();
_logger.LogInformation("Fetching all products. CorrelationId: {CorrelationId}", correlationId);
var products = await _db.Products
.Where(p => p.IsActive)
.ToListAsync();
return Ok(products);
}
/// <summary>
/// Отримати продукт за ID
/// </summary>
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(ApiResponse<Product>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Product>> GetById(int id)
{
var product = await _db.Products.FindAsync(id);
if (product is null)
return NotFound();
return Ok(product);
}
/// <summary>
/// Створити новий продукт
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(ApiResponse<Product>), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<ActionResult<Product>> Create(CreateProductDto dto)
{
// Валідація вже пройшла через RequestValidationFilter
var product = new Product
{
Name = dto.Name,
Price = dto.Price,
Stock = dto.Stock
};
_db.Products.Add(product);
await _db.SaveChangesAsync();
return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
}
/// <summary>
/// Видалити продукт
/// </summary>
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id)
{
var product = await _db.Products.FindAsync(id);
if (product is null)
return NotFound();
_db.Products.Remove(product);
await _db.SaveChangesAsync();
return NoContent();
}
}
Ключові моменти:
HttpContext.Items (CorrelationId, ApiKey)ApiResponse<T> для Swagger документаціїФільтри виконуються у порядку їх реєстрації, але можна явно вказати порядок:
public class HighPriorityFilter : IAsyncActionFilter, IOrderedFilter
{
public int Order => -100; // Виконається раніше (менше число = вища пріоритет)
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
// ...
await next();
}
}
public class LowPriorityFilter : IAsyncActionFilter, IOrderedFilter
{
public int Order => 100; // Виконається пізніше
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
// ...
await next();
}
}
Порядок виконання:
HighPriorityFilter (Order: -100)
→ NormalFilter (Order: 0, за замовчуванням)
→ LowPriorityFilter (Order: 100)
→ Action Method
← LowPriorityFilter
← NormalFilter
← HighPriorityFilter
Фільтр може зупинити виконання pipeline, встановивши context.Result:
public class CacheFilter : IAsyncActionFilter
{
private readonly IMemoryCache _cache;
public CacheFilter(IMemoryCache cache)
{
_cache = cache;
}
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
var cacheKey = context.HttpContext.Request.Path.ToString();
// Перевіряємо кеш
if (_cache.TryGetValue(cacheKey, out object? cachedResult))
{
// Short-circuit: повертаємо з кешу, action не виконується
context.Result = new OkObjectResult(cachedResult);
return; // НЕ викликаємо next()
}
// Виконуємо action
var resultContext = await next();
// Кешуємо результат
if (resultContext.Result is OkObjectResult okResult)
{
_cache.Set(cacheKey, okResult.Value, TimeSpan.FromMinutes(5));
}
}
}
Фільтр може вирішувати, чи застосовувати логіку:
public class ConditionalLoggingFilter : IAsyncActionFilter
{
private readonly ILogger<ConditionalLoggingFilter> _logger;
public ConditionalLoggingFilter(ILogger<ConditionalLoggingFilter> logger)
{
_logger = logger;
}
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
// Логуємо тільки POST/PUT/DELETE
var shouldLog = context.HttpContext.Request.Method != "GET";
if (shouldLog)
{
_logger.LogInformation(
"Modifying request: {Method} {Path}",
context.HttpContext.Request.Method,
context.HttpContext.Request.Path);
}
await next();
}
}
Створення кастомного атрибута для конфігурації фільтра:
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class RequireApiKeyAttribute : Attribute, IFilterFactory
{
public bool IsReusable => false;
public IFilterMetadata CreateInstance(IServiceProvider serviceProvider)
{
return serviceProvider.GetRequiredService<ApiKeyAuthFilter>();
}
}
// Використання
[RequireApiKey] // Замість [ServiceFilter(typeof(ApiKeyAuthFilter))]
public class ProductsController : ControllerBase
{
// ...
}
У якому порядку виконаються наступні фільтри?
options.Filters.Add<FilterA>(); // Order: 0
options.Filters.Add<FilterB>(); // Order: -50
options.Filters.Add<FilterC>(); // Order: 100
Порядок виконання (до action):
Фільтри з меншим Order виконуються раніше.
Який тип фільтра використати для кожного сценарію?
IAuthorizationFilter) — виконується першимIActionFilter) — до/після actionIResultFilter) — модифікація результатуIResourceFilter) — після авторизації, до model bindingСтворіть фільтр, що обмежує кількість запитів до 10 на хвилину для кожного API-ключа:
public class RateLimitingFilter : IAsyncActionFilter
{
private static readonly Dictionary<string, Queue<DateTime>> _requestHistory = new();
private static readonly object _lock = new();
private const int MaxRequestsPerMinute = 10;
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
var apiKey = context.HttpContext.Items["ApiKey"]?.ToString();
if (string.IsNullOrEmpty(apiKey))
{
await next();
return;
}
lock (_lock)
{
if (!_requestHistory.ContainsKey(apiKey))
{
_requestHistory[apiKey] = new Queue<DateTime>();
}
var history = _requestHistory[apiKey];
var now = DateTime.UtcNow;
var oneMinuteAgo = now.AddMinutes(-1);
// Видаляємо старі записи
while (history.Count > 0 && history.Peek() < oneMinuteAgo)
{
history.Dequeue();
}
if (history.Count >= MaxRequestsPerMinute)
{
var retryAfter = (int)(history.Peek().AddMinutes(1) - now).TotalSeconds;
context.HttpContext.Response.Headers.Append("Retry-After", retryAfter.ToString());
context.Result = new ObjectResult(new ProblemDetails
{
Status = StatusCodes.Status429TooManyRequests,
Title = "Rate Limit Exceeded",
Detail = $"Maximum {MaxRequestsPerMinute} requests per minute allowed"
})
{
StatusCode = StatusCodes.Status429TooManyRequests
};
return;
}
history.Enqueue(now);
}
await next();
}
}
::
::
Створіть фільтр, що логує тіло запиту та відповіді для аудиту:
public class RequestResponseLoggingFilter : IAsyncActionFilter
{
private readonly ILogger<RequestResponseLoggingFilter> _logger;
public RequestResponseLoggingFilter(ILogger<RequestResponseLoggingFilter> logger)
{
_logger = logger;
}
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
var correlationId = context.HttpContext.Items["CorrelationId"]?.ToString() ?? "N/A";
var method = context.HttpContext.Request.Method;
var path = context.HttpContext.Request.Path;
// Логуємо запит
var requestBody = await ReadRequestBody(context.HttpContext.Request);
_logger.LogInformation(
"[{CorrelationId}] Request: {Method} {Path} | Body: {Body}",
correlationId,
method,
path,
requestBody);
// Виконуємо action
var resultContext = await next();
// Логуємо відповідь
if (resultContext.Result is ObjectResult objectResult)
{
var responseBody = System.Text.Json.JsonSerializer.Serialize(objectResult.Value);
_logger.LogInformation(
"[{CorrelationId}] Response: {StatusCode} | Body: {Body}",
correlationId,
objectResult.StatusCode ?? 200,
responseBody);
}
}
private async Task<string> ReadRequestBody(HttpRequest request)
{
if (request.ContentLength == null || request.ContentLength == 0)
return "empty";
request.EnableBuffering(); // Дозволяє читати Body кілька разів
using var reader = new StreamReader(
request.Body,
encoding: System.Text.Encoding.UTF8,
detectEncodingFromByteOrderMarks: false,
leaveOpen: true);
var body = await reader.ReadToEndAsync();
request.Body.Position = 0; // Повертаємо позицію для наступного читання
return string.IsNullOrEmpty(body) ? "empty" : body;
}
}
Ключові моменти:
request.EnableBuffering() — дозволяє читати Body кілька разів (за замовчуванням можна лише один раз)request.Body.Position = 0 — повертаємо позицію після читанняСтворіть фільтр, що додає заголовок Content-Encoding: gzip для великих відповідей:
public class ConditionalCompressionFilter : IAsyncResultFilter
{
private const int CompressionThresholdBytes = 1024; // 1 KB
public async Task OnResultExecutionAsync(
ResultExecutingContext context,
ResultExecutionDelegate next)
{
if (context.Result is ObjectResult objectResult && objectResult.Value != null)
{
var json = System.Text.Json.JsonSerializer.Serialize(objectResult.Value);
var sizeBytes = System.Text.Encoding.UTF8.GetByteCount(json);
if (sizeBytes > CompressionThresholdBytes)
{
context.HttpContext.Response.Headers.Append("X-Original-Size", sizeBytes.ToString());
context.HttpContext.Response.Headers.Append("X-Compression-Eligible", "true");
}
}
await next();
}
}
::
Створіть систему фільтрів для multi-tenant API, де кожен API-ключ прив'язаний до tenant:
1. Модель Tenant:
public record TenantInfo
{
public required string TenantId { get; init; }
public required string Name { get; init; }
public required string[] AllowedEndpoints { get; init; }
public int RateLimitPerMinute { get; init; } = 100;
}
2. Tenant Service:
public interface ITenantService
{
TenantInfo? GetTenantByApiKey(string apiKey);
}
public class TenantService : ITenantService
{
private readonly Dictionary<string, TenantInfo> _tenants = new()
{
["sk_tenant_a_abc123"] = new TenantInfo
{
TenantId = "tenant-a",
Name = "Company A",
AllowedEndpoints = new[] { "/api/products", "/api/orders" },
RateLimitPerMinute = 50
},
["sk_tenant_b_xyz789"] = new TenantInfo
{
TenantId = "tenant-b",
Name = "Company B",
AllowedEndpoints = new[] { "/api/products" },
RateLimitPerMinute = 100
}
};
public TenantInfo? GetTenantByApiKey(string apiKey)
{
return _tenants.TryGetValue(apiKey, out var tenant) ? tenant : null;
}
}
3. Multi-Tenant Auth Filter:
public class MultiTenantAuthFilter : IAsyncAuthorizationFilter
{
private const string ApiKeyHeaderName = "X-Api-Key";
private readonly ITenantService _tenantService;
private readonly ILogger<MultiTenantAuthFilter> _logger;
public MultiTenantAuthFilter(ITenantService tenantService, ILogger<MultiTenantAuthFilter> logger)
{
_tenantService = tenantService;
_logger = logger;
}
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
{
// Перевіряємо наявність API-ключа
if (!context.HttpContext.Request.Headers.TryGetValue(ApiKeyHeaderName, out var apiKey))
{
context.Result = new UnauthorizedObjectResult(new ProblemDetails
{
Status = StatusCodes.Status401Unauthorized,
Title = "API Key Required",
Detail = $"API key must be provided in {ApiKeyHeaderName} header"
});
return;
}
// Отримуємо tenant за API-ключем
var tenant = _tenantService.GetTenantByApiKey(apiKey!);
if (tenant is null)
{
_logger.LogWarning("Invalid API key attempted: {ApiKey}", apiKey);
context.Result = new UnauthorizedObjectResult(new ProblemDetails
{
Status = StatusCodes.Status401Unauthorized,
Title = "Invalid API Key",
Detail = "The provided API key is not valid"
});
return;
}
// Перевіряємо доступ до endpoint
var path = context.HttpContext.Request.Path.Value ?? "";
var hasAccess = tenant.AllowedEndpoints.Any(endpoint =>
path.StartsWith(endpoint, StringComparison.OrdinalIgnoreCase));
if (!hasAccess)
{
_logger.LogWarning(
"Tenant {TenantId} attempted to access forbidden endpoint: {Path}",
tenant.TenantId,
path);
context.Result = new ObjectResult(new ProblemDetails
{
Status = StatusCodes.Status403Forbidden,
Title = "Access Denied",
Detail = $"Your subscription does not include access to {path}"
})
{
StatusCode = StatusCodes.Status403Forbidden
};
return;
}
// Зберігаємо tenant у HttpContext
context.HttpContext.Items["Tenant"] = tenant;
_logger.LogInformation(
"Tenant {TenantId} ({TenantName}) authenticated for {Path}",
tenant.TenantId,
tenant.Name,
path);
await Task.CompletedTask;
}
}
4. Tenant-Aware Rate Limiting Filter:
public class TenantRateLimitingFilter : IAsyncActionFilter
{
private static readonly Dictionary<string, Queue<DateTime>> _requestHistory = new();
private static readonly object _lock = new();
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
var tenant = context.HttpContext.Items["Tenant"] as TenantInfo;
if (tenant is null)
{
await next();
return;
}
lock (_lock)
{
if (!_requestHistory.ContainsKey(tenant.TenantId))
{
_requestHistory[tenant.TenantId] = new Queue<DateTime>();
}
var history = _requestHistory[tenant.TenantId];
var now = DateTime.UtcNow;
var oneMinuteAgo = now.AddMinutes(-1);
// Видаляємо старі записи
while (history.Count > 0 && history.Peek() < oneMinuteAgo)
{
history.Dequeue();
}
if (history.Count >= tenant.RateLimitPerMinute)
{
var retryAfter = (int)(history.Peek().AddMinutes(1) - now).TotalSeconds;
context.HttpContext.Response.Headers.Append("Retry-After", retryAfter.ToString());
context.HttpContext.Response.Headers.Append("X-RateLimit-Limit", tenant.RateLimitPerMinute.ToString());
context.HttpContext.Response.Headers.Append("X-RateLimit-Remaining", "0");
context.Result = new ObjectResult(new ProblemDetails
{
Status = StatusCodes.Status429TooManyRequests,
Title = "Rate Limit Exceeded",
Detail = $"Maximum {tenant.RateLimitPerMinute} requests per minute allowed for your subscription"
})
{
StatusCode = StatusCodes.Status429TooManyRequests
};
return;
}
history.Enqueue(now);
// Додаємо rate limit headers
context.HttpContext.Response.Headers.Append("X-RateLimit-Limit", tenant.RateLimitPerMinute.ToString());
context.HttpContext.Response.Headers.Append("X-RateLimit-Remaining", (tenant.RateLimitPerMinute - history.Count).ToString());
}
await next();
}
}
5. Реєстрація:
// Program.cs
builder.Services.AddSingleton<ITenantService, TenantService>();
builder.Services.AddScoped<MultiTenantAuthFilter>();
builder.Services.AddScoped<TenantRateLimitingFilter>();
builder.Services.AddControllers(options =>
{
options.Filters.Add<MultiTenantAuthFilter>();
options.Filters.Add<TenantRateLimitingFilter>();
});
6. Використання у контролері:
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
[HttpGet]
public IActionResult GetAll()
{
var tenant = HttpContext.Items["Tenant"] as TenantInfo;
// Фільтруємо продукти за tenant
var products = GetProductsForTenant(tenant!.TenantId);
return Ok(products);
}
}
Результат:
GET /api/products
X-Api-Key: sk_tenant_a_abc123
HTTP/1.1 200 OK
X-RateLimit-Limit: 50
X-RateLimit-Remaining: 49
GET /api/analytics
X-Api-Key: sk_tenant_a_abc123
HTTP/1.1 403 Forbidden
{
"title": "Access Denied",
"detail": "Your subscription does not include access to /api/analytics"
}
Створіть систему аудиту, що записує всі зміни даних (POST/PUT/DELETE) у базу даних:
1. Модель аудиту:
public class AuditLog
{
public int Id { get; set; }
public required string TenantId { get; set; }
public required string UserId { get; set; }
public required string Action { get; set; } // CREATE, UPDATE, DELETE
public required string Resource { get; set; } // products, orders
public string? ResourceId { get; set; }
public string? RequestBody { get; set; }
public string? ResponseBody { get; set; }
public int StatusCode { get; set; }
public required string IpAddress { get; set; }
public required string UserAgent { get; set; }
public required string CorrelationId { get; set; }
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
}
2. Audit Service:
public interface IAuditService
{
Task LogAsync(AuditLog log);
}
public class AuditService : IAuditService
{
private readonly ILogger<AuditService> _logger;
// У production: inject DbContext
public AuditService(ILogger<AuditService> logger)
{
_logger = logger;
}
public async Task LogAsync(AuditLog log)
{
// У production: зберігаємо у БД
_logger.LogInformation(
"AUDIT: {Action} {Resource}/{ResourceId} by {UserId} ({TenantId}) - {StatusCode}",
log.Action,
log.Resource,
log.ResourceId ?? "N/A",
log.UserId,
log.TenantId,
log.StatusCode);
await Task.CompletedTask;
}
}
3. Audit Filter:
public class AuditTrailFilter : IAsyncActionFilter
{
private readonly IAuditService _auditService;
public AuditTrailFilter(IAuditService auditService)
{
_auditService = auditService;
}
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
var method = context.HttpContext.Request.Method;
// Аудит тільки для модифікуючих операцій
if (method != "POST" && method != "PUT" && method != "DELETE")
{
await next();
return;
}
// Збираємо дані запиту
var tenant = context.HttpContext.Items["Tenant"] as TenantInfo;
var correlationId = context.HttpContext.Items["CorrelationId"]?.ToString() ?? Guid.NewGuid().ToString();
var requestBody = await ReadRequestBody(context.HttpContext.Request);
// Виконуємо action
var resultContext = await next();
// Збираємо дані відповіді
var statusCode = context.HttpContext.Response.StatusCode;
var responseBody = resultContext.Result is ObjectResult objectResult
? System.Text.Json.JsonSerializer.Serialize(objectResult.Value)
: null;
// Визначаємо action type
var actionType = method switch
{
"POST" => "CREATE",
"PUT" => "UPDATE",
"DELETE" => "DELETE",
_ => "UNKNOWN"
};
// Витягуємо resource та ID з маршруту
var routeData = context.RouteData;
var controller = routeData.Values["controller"]?.ToString()?.ToLower() ?? "unknown";
var resourceId = routeData.Values["id"]?.ToString();
// Створюємо audit log
var auditLog = new AuditLog
{
TenantId = tenant?.TenantId ?? "anonymous",
UserId = context.HttpContext.User.Identity?.Name ?? "anonymous",
Action = actionType,
Resource = controller,
ResourceId = resourceId,
RequestBody = requestBody,
ResponseBody = responseBody,
StatusCode = statusCode,
IpAddress = context.HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
UserAgent = context.HttpContext.Request.Headers.UserAgent.ToString(),
CorrelationId = correlationId
};
// Зберігаємо асинхронно (fire-and-forget для продуктивності)
_ = Task.Run(() => _auditService.LogAsync(auditLog));
}
private async Task<string?> ReadRequestBody(HttpRequest request)
{
if (request.ContentLength == null || request.ContentLength == 0)
return null;
request.EnableBuffering();
using var reader = new StreamReader(
request.Body,
encoding: System.Text.Encoding.UTF8,
detectEncodingFromByteOrderMarks: false,
leaveOpen: true);
var body = await reader.ReadToEndAsync();
request.Body.Position = 0;
return body;
}
}
4. Реєстрація:
builder.Services.AddScoped<IAuditService, AuditService>();
builder.Services.AddScoped<AuditTrailFilter>();
builder.Services.AddControllers(options =>
{
options.Filters.Add<AuditTrailFilter>();
});
Результат у логах:
[INFO] AUDIT: CREATE products/N/A by john@company-a.com (tenant-a) - 201
[INFO] AUDIT: UPDATE products/5 by jane@company-b.com (tenant-b) - 200
[INFO] AUDIT: DELETE products/3 by admin@company-a.com (tenant-a) - 204
Створіть фільтр для керування доступом до експериментальних features через feature flags:
1. Feature Flag Service:
public interface IFeatureFlagService
{
bool IsEnabled(string featureName, string? tenantId = null);
}
public class FeatureFlagService : IFeatureFlagService
{
private readonly IConfiguration _configuration;
public FeatureFlagService(IConfiguration configuration)
{
_configuration = configuration;
}
public bool IsEnabled(string featureName, string? tenantId = null)
{
// Глобальні feature flags
var globalFlags = _configuration.GetSection("FeatureFlags").Get<Dictionary<string, bool>>()
?? new Dictionary<string, bool>();
if (globalFlags.TryGetValue(featureName, out var isEnabled))
{
return isEnabled;
}
// Tenant-specific feature flags
if (tenantId != null)
{
var tenantFlags = _configuration.GetSection($"FeatureFlags:Tenants:{tenantId}").Get<Dictionary<string, bool>>();
if (tenantFlags?.TryGetValue(featureName, out var tenantEnabled) == true)
{
return tenantEnabled;
}
}
return false; // За замовчуванням вимкнено
}
}
2. Feature Flag Attribute:
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class RequireFeatureAttribute : Attribute
{
public string FeatureName { get; }
public RequireFeatureAttribute(string featureName)
{
FeatureName = featureName;
}
}
3. Feature Flag Filter:
public class FeatureFlagFilter : IAsyncActionFilter
{
private readonly IFeatureFlagService _featureFlagService;
private readonly ILogger<FeatureFlagFilter> _logger;
public FeatureFlagFilter(IFeatureFlagService featureFlagService, ILogger<FeatureFlagFilter> logger)
{
_featureFlagService = featureFlagService;
_logger = logger;
}
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
// Перевіряємо наявність атрибута
var featureAttribute = context.ActionDescriptor.EndpointMetadata
.OfType<RequireFeatureAttribute>()
.FirstOrDefault();
if (featureAttribute is null)
{
await next();
return;
}
// Отримуємо tenant
var tenant = context.HttpContext.Items["Tenant"] as TenantInfo;
// Перевіряємо feature flag
var isEnabled = _featureFlagService.IsEnabled(featureAttribute.FeatureName, tenant?.TenantId);
if (!isEnabled)
{
_logger.LogWarning(
"Feature {FeatureName} is disabled for tenant {TenantId}",
featureAttribute.FeatureName,
tenant?.TenantId ?? "anonymous");
context.Result = new ObjectResult(new ProblemDetails
{
Status = StatusCodes.Status403Forbidden,
Title = "Feature Not Available",
Detail = $"The feature '{featureAttribute.FeatureName}' is not available for your account"
})
{
StatusCode = StatusCodes.Status403Forbidden
};
return;
}
_logger.LogInformation(
"Feature {FeatureName} is enabled for tenant {TenantId}",
featureAttribute.FeatureName,
tenant?.TenantId ?? "anonymous");
await next();
}
}
4. Конфігурація (appsettings.json):
{
"FeatureFlags": {
"AdvancedAnalytics": false,
"BulkExport": true,
"AIRecommendations": false,
"Tenants": {
"tenant-a": {
"AdvancedAnalytics": true,
"AIRecommendations": true
}
}
}
}
5. Використання:
[ApiController]
[Route("api/[controller]")]
public class AnalyticsController : ControllerBase
{
[HttpGet("advanced")]
[RequireFeature("AdvancedAnalytics")] // Доступно тільки для tenant-a
public IActionResult GetAdvancedAnalytics()
{
return Ok(new { message = "Advanced analytics data" });
}
[HttpGet("basic")]
public IActionResult GetBasicAnalytics()
{
return Ok(new { message = "Basic analytics data" });
}
}
6. Реєстрація:
builder.Services.AddSingleton<IFeatureFlagService, FeatureFlagService>();
builder.Services.AddScoped<FeatureFlagFilter>();
builder.Services.AddControllers(options =>
{
options.Filters.Add<FeatureFlagFilter>();
});
Результат:
GET /api/analytics/advanced
X-Api-Key: sk_tenant_a_abc123
HTTP/1.1 200 OK
{ "message": "Advanced analytics data" }
GET /api/analytics/advanced
X-Api-Key: sk_tenant_b_xyz789
HTTP/1.1 403 Forbidden
{
"title": "Feature Not Available",
"detail": "The feature 'AdvancedAnalytics' is not available for your account"
}
У цій статті ви навчилися використовувати фільтри для реалізації cross-cutting concerns у Web API:
1. Типи фільтрів для API:
2. Filter Pipeline:
IOrderedFilter дозволяє контролювати порядок виконанняcontext.Result зупиняє pipeline3. Реєстрація фільтрів:
options.Filters.Add<T>() для всіх endpoints[ServiceFilter(typeof(T))] для всіх методів[ServiceFilter(typeof(T))] для конкретного endpoint4. Практичні патерни:
✅ Централізація логіки — без дублювання коду
✅ Чистий код контролерів — бізнес-логіка не захаращена технічними деталями
✅ Тестованість — фільтри легко тестувати ізольовано
✅ Консистентність — однакова поведінка для всіх endpoints
✅ Масштабованість — легко додавати нові cross-cutting concerns
| Сценарій | Рішення |
|---|---|
| Валідація API-ключа | Authorization Filter |
| Логування запитів/відповідей | Action Filter |
| Вимірювання продуктивності | Action Filter |
| Обгортання відповідей | Result Filter |
| Додавання headers | Result Filter |
| Rate limiting | Action Filter |
| Multi-tenancy | Authorization + Action Filters |
| Audit trail | Action Filter |
| Feature flags | Action Filter |
Офіційна документація
Filter Pipeline
ProblemDetails
Best Practices
PagedList<T>, query-based фільтрація, dynamic сортування та HATEOAS links.ProblemDetails та структурована обробка помилок
Реалізація RFC 9457 для консистентних помилок API. GlobalExceptionHandler, IExceptionHandler, IProblemDetailsService, кастомні error codes та traceability.
Пагінація, фільтрація та сортування
Практична реалізація PagedList<T>, query-based фільтрація через DTO, dynamic сортування, X-Pagination headers, HATEOAS links та cursor-based пагінація для великих датасетів.