08API Design и Communication
REST API Best Practices
# REST API Best Practices
Resource-Oriented Design
Nouns vs Verbs
REST APIs should use nouns (resources) in URLs, with HTTP methods defining the action:
# Correct (Nouns)
GET /api/orders -- list orders
POST /api/orders -- create order
GET /api/orders/123 -- get order 123
PUT /api/orders/123 -- fully update order
DELETE /api/orders/123 -- delete order
# Incorrect (Verbs)
GET /api/getOrders
POST /api/createOrderResource Naming Conventions
| Rule | Example |
|---|---|
| Use plural nouns | /api/users, not /api/user |
| Lowercase with hyphens | /api/product-categories |
| Nested navigation | /api/users/123/orders |
| No actions in URL | /api/orders/123/cancel (use POST to resource) |
| Query params for filters | /api/products?category=electronics&min-price=100 |
Nested Resources
GET /api/users/123/orders -- user 123's orders
POST /api/users/123/orders -- create order for user 123
GET /api/users/123/orders/456 -- order 456 of user 123
DELETE /api/users/123/orders/456 -- delete order 456 of user 123HTTP Methods Semantics
Semantics Table
| Method | Safe | Idempotent | Body | Semantics |
|---|---|---|---|---|
| GET | Yes | Yes | No | Retrieve resource without side effects |
| POST | No | No | Optional | Create resource or perform operation |
| PUT | No | Yes | Yes | Fully replace resource |
| PATCH | No | No | Yes | Partial update |
| DELETE | No | Yes | No | Delete resource |
Idempotency
- GET, PUT, DELETE -- idempotent (multiple calls produce same result)
- POST, PATCH -- not idempotent
For idempotent POST operations, use Idempotency-Key header:
POST /api/payments
Idempotency-Key: unique-key-12345
Content-Type: application/json
{"amount": 100, "currency": "USD"}Status Codes
xx -- Success
| Code | Meaning | When to Use |
|---|---|---|
| 200 OK | Success | GET, PUT, PATCH succeed |
| 201 Created | Created | POST creates new resource |
| 202 Accepted | Accepted | Async processing |
| 204 No Content | No Content | DELETE or PUT without response body |
xx -- Redirection
| Code | Meaning | When to Use |
|---|---|---|
| 301 Moved Permanently | Permanent redirect | Resource moved permanently |
| 302 Found | Temporary redirect | Temporary redirection |
| 304 Not Modified | Not Modified | Caching, ETag matched |
| 307 Temporary Redirect | Temporary redirect | Preserves request method |
xx -- Client Errors
| Code | Meaning | When to Use |
|---|---|---|
| 400 Bad Request | Bad Request | Invalid format, validation |
| 401 Unauthorized | Unauthorized | Missing or invalid token |
| 403 Forbidden | Forbidden | Authenticated but no permissions |
| 404 Not Found | Not Found | Resource does not exist |
| 405 Method Not Allowed | Method Not Allowed | Wrong HTTP method |
| 409 Conflict | Conflict | Duplicate, state conflict |
| 410 Gone | Gone | Resource permanently removed (sunset) |
| 415 Unsupported Media Type | Wrong Content-Type | Invalid Content-Type |
| 422 Unprocessable Entity | Unprocessable | Semantic validation error |
| 429 Too Many Requests | Rate Limited | Rate limiting |
xx -- Server Errors
| Code | Meaning | When to Use |
|---|---|---|
| 500 Internal Server Error | Internal Error | Unexpected server error |
| 502 Bad Gateway | Bad Gateway | Upstream service error |
| 503 Service Unavailable | Unavailable | Service temporarily unavailable |
| 504 Gateway Timeout | Gateway Timeout | Upstream did not respond in time |
HATEOAS
HATEOAS (Hypermedia as the Engine of Application State) -- REST constraint where clients navigate APIs through links in responses.
Example HATEOAS Response
{
"id": 123,
"name": "Order #123",
"status": "pending",
"total": 250.00,
"_links": {
"self": {
"href": "/api/orders/123"
},
"cancel": {
"href": "/api/orders/123/cancel",
"method": "POST",
"title": "Cancel this order"
},
"pay": {
"href": "/api/orders/123/pay",
"method": "POST",
"title": "Pay for this order"
},
"parent": {
"href": "/api/orders"
}
}
}State-dependent Links
Links depend on resource state:
// Order status: "pending"
{
"_links": {
"self": { "href": "/api/orders/123" },
"cancel": { "href": "/api/orders/123/cancel", "method": "POST" },
"pay": { "href": "/api/orders/123/pay", "method": "POST" },
"update": { "href": "/api/orders/123", "method": "PUT" }
}
}
// Order status: "confirmed"
{
"_links": {
"self": { "href": "/api/orders/123" },
"ship": { "href": "/api/orders/123/ship", "method": "POST" },
"cancel": { "href": "/api/orders/123/cancel", "method": "POST" }
}
}API Entry Point (Root)
{
"_links": {
"self": { "href": "/api" },
"orders": { "href": "/api/orders", "title": "Order management" },
"customers": { "href": "/api/customers", "title": "Customer management" },
"products": { "href": "/api/products", "title": "Product catalog" },
"docs": { "href": "/api/docs", "title": "API documentation" }
}
}Hypermedia Formats
| Format | Media Type | Description |
|---|---|---|
| HAL | application/hal+json | Standard with _links and _embedded |
| Siren | application/vnd.siren+json | Includes actions and entities |
| JSON:API | application/vnd.api+json | Structured format with links |
| Custom | application/json | Custom _links |
Pagination
Offset Pagination
GET /api/posts?page=3&limit=20{
"data": [ /* 20 posts */ ],
"pagination": {
"page": 3,
"limit": 20,
"total": 15420,
"totalPages": 771
}
}Pros: Simple implementation, supports jump to any page, total count Cons: O(n) at large offsets, unstable under data changes
Cursor-Based Pagination (Recommended)
GET /api/posts?limit=20&cursor=eyJpZCI6MTIzfQ=={
"data": [ /* 20 posts */ ],
"pagination": {
"limit": 20,
"hasNextPage": true,
"nextCursor": "eyJpZCI6MTAwfQ==",
"prevCursor": "eyJpZCI6NjB9"
}
}Pros: O(1) performance, stable under changes, ideal for infinite scroll Cons: No random page access, cursor is opaque
Keyset Pagination (Database Level)
-- Cursor = {created_at: '2026-01-15', id: 100}
SELECT * FROM posts
WHERE (created_at, id) < ('2026-01-15', 100)
ORDER BY created_at DESC, id DESC
LIMIT 20;Comparison Table
| Pattern | Jump to page | Stable under writes | Performance at depth | Total count |
|---|---|---|---|---|
| Offset | Yes | No | Degrades (O(n)) | Easy |
| Cursor | No | Yes | Constant (O(1)) | Hard |
| Keyset | No | Yes | Constant (O(1)) | Hard |
Implementation: Cursor-Based Pagination in C#
// Cursor encoding/decoding
public static class CursorHelper
{
public static string Encode(int id, DateTime createdAt)
{
var json = JsonSerializer.Serialize(new { id, createdAt });
return Convert.ToBase64UrlString(Encoding.UTF8.GetBytes(json));
}
public static (int id, DateTime createdAt)? Decode(string cursor)
{
var bytes = Convert.FromBase64UrlString(cursor);
var json = Encoding.UTF8.GetString(bytes);
return JsonSerializer.Deserialize<(int id, DateTime createdAt)>(json);
}
}
// Endpoint implementation
app.MapGet("/api/posts", async (
ApplicationDb db,
int limit = 20,
string? cursor = null) =>
{
limit = Math.Min(limit, 100); // max page size
var query = db.Posts
.OrderByDescending(p => p.CreatedAt)
.ThenByDescending(p => p.Id);
if (cursor is not null)
{
var (lastId, lastCreatedAt) = CursorHelper.Decode(cursor)
?? throw new InvalidOperationException("Invalid cursor");
query = query.Where(p =>
(p.CreatedAt, p.Id) < (lastCreatedAt, lastId));
}
var posts = await query
.Take(limit + 1)
.Select(p => new { p.Id, p.Title, p.CreatedAt })
.ToListAsync();
var hasNext = posts.Count > limit;
if (hasNext) posts.RemoveAt(limit);
var nextCursor = hasNext
? CursorHelper.Encode(posts.Last().Id, posts.Last().CreatedAt)
: null;
var prevCursor = cursor; // For previous, reverse the query
return Results.Ok(new
{
data = posts,
pagination = new
{
limit,
hasNextPage = hasNext,
nextCursor,
prevCursor
}
});
});Filtering, Sorting, Field Selection
Filtering
GET /api/products?category=electronics&min-price=100&max-price=1000&in-stock=trueSorting
GET /api/products?sort=-price,name # sort by price desc, then name asc
GET /api/products?sort=created_at # sort by created_at asc (default)Field Selection (Sparse Fieldsets)
GET /api/products?fields=id,name,price
GET /api/users?fields=id,name,email,profile(bio,avatar)Combined Example
GET /api/products?category=electronics&sort=-price&fields=id,name,price&limit=20&cursor=abc123Practice: Blog Platform REST API Design
Resource Model
/api/posts GET (list), POST (create)
/api/posts/{id} GET (get), PUT (update), PATCH (partial), DELETE
/api/posts/{id}/comments GET (list comments)
/api/comments/{id} GET (get), PUT, DELETE
/api/users GET (list), POST (register)
/api/users/{id} GET (get), PUT, PATCH, DELETE
/api/users/{id}/posts GET (get user posts)
/api/tags GET (list)
/api/tags/{slug}/posts GET (posts by tag)
/api/categories GET (list)
/api/categories/{slug}/posts GET (posts by category)Example Responses
// GET /api/posts
{
"data": [
{
"id": 1,
"title": "Getting Started with .NET",
"slug": "getting-started-dotnet",
"createdAt": "2026-01-15T10:00:00Z",
"author": { "id": 1, "name": "John Doe" }
}
],
"pagination": {
"limit": 20,
"hasNextPage": true,
"nextCursor": "eyJpZCI6MTkzfQ=="
},
"_links": {
"self": { "href": "/api/posts" },
"next": { "href": "/api/posts?cursor=eyJpZCI6MTkzfQ==" },
"create": { "href": "/api/posts", "method": "POST" }
}
}References
- [REST API Design - Paul Ammann](https://restfulapi.net/)
- [HTTP Status Codes - MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status)
- [RFC 7807 - Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc7807)
- [HATEOAS Best Practices](https://asoasis.tech/articles/2026-03-22-0253-rest-api-hateoas-implementation/)
Практика
ASP.NET Core Web API
# ASP.NET Core Web API
Minimal APIs vs Controllers
When to Use Each
| Criterion | Minimal APIs | Controllers |
|---|---|---|
| Project size | Small to medium | Large, complex |
| Verbosity | Minimal code | More boilerplate |
| DI support | Full DI support | Full DI support |
| Model binding | Supported | Full support + extensibility |
| Validation | Manual or FluentValidation | Built-in + FluentValidation |
| Filters | Limited (middleware pattern) | Action, Result, Exception filters |
| OData | Not supported | Supported |
| API versioning | Manual setup | Built-in with Microsoft.AspNetCore.Mvc.Versioning |
| Testing | Simple | Well-established patterns |
| Learning curve | Lower | Standard ASP.NET Core |
Recommendation
> Start with Minimal APIs for new projects. Use Controllers when you need advanced features: model binding extensibility, form binding (IFormFile), OData, or sophisticated validation.
Minimal APIs
Basic Setup (.NET 8+)
`csharp var builder = WebApplication.CreateBuilder(args);
// DI builder.Services.AddDbContext
var app = builder.Build();
app.UseProblemDetails();
// POST with validation app.MapPost("/api/posts", async ( [FromBody] CreatePostRequest request, [FromServices] IValidator
var post = new Post { Title = request.Title, Content = request.Content, AuthorId = request.AuthorId };
db.Posts.Add(post); await db.SaveChangesAsync();
return Results.Created($"/api/posts/{post.Id}", post); });
// GET with pagination app.MapGet("/api/posts", async ( [FromQuery] int limit = 20, [FromQuery] string? cursor = null, [FromServices] ApplicationDb db) => { var query = db.Posts .OrderByDescending(p => p.CreatedAt) .ThenByDescending(p => p.Id);
if (cursor is not null) { var (lastId, lastCreatedAt) = CursorHelper.Decode(cursor) ?? throw new InvalidOperationException("Invalid cursor"); query = query.Where(p => (p.CreatedAt, p.Id) < (lastCreatedAt, lastId)); }
var posts = await query .Take(limit + 1) .Select(p => new { p.Id, p.Title, p.CreatedAt }) .ToListAsync();
var hasNext = posts.Count > limit; if (hasNext) posts.RemoveAt(limit);
return Results.Ok(new { data = posts, pagination = new { hasNextPage = hasNext, nextCursor = hasNext ? CursorHelper.Encode(posts.Last().Id, posts.Last().CreatedAt) : null } }); });
app.Run(); `
Typed Results (.NET 8+)
`csharp // Instead of returning IActionResult, use typed results app.MapGet("/api/posts/{id}", async (int id, ApplicationDb db) => { var post = await db.Posts.FindAsync(id); return post is null ? Results.NotFound() : Results.Ok(post); });
// With ProblemDetails app.MapDelete("/api/posts/{id}", async (int id, ApplicationDb db) => { var post = await db.Posts.FindAsync(id); if (post is null) return Results.Problem( detail: $"Post with id {id} not found", statusCode: 404);
db.Posts.Remove(post); await db.SaveChangesAsync(); return Results.NoContent(); }); `
Grouping and Ordering
`csharp var posts = app.MapGroup("/api/posts");
posts.MapGet("/", GetPosts); posts.MapGet("/{id}", GetPost); posts.MapPost("/", CreatePost); posts.MapPut("/{id}", UpdatePost); posts.MapPatch("/{id}", PatchPost); posts.MapDelete("/{id}", DeletePost);
// Apply common filters posts.WithMetadata(new AuthorizeAttribute("Admin")); posts.WithOpenApi(); // OpenAPI documentation `
Controllers
Basic Controller
`csharp [ApiController] [Route("api/[controller]")] public class PostsController : ControllerBase { private readonly ApplicationDb _db;
public PostsController(ApplicationDb db) { _db = db; }
[HttpGet] public async Task
if (cursor is not null) { var (lastId, lastCreatedAt) = CursorHelper.Decode(cursor); if (lastId.HasValue) query = query.Where(p => (p.CreatedAt, p.Id) < (lastCreatedAt.Value, lastId.Value)); }
var posts = await query .Take(limit + 1) .Select(p => new { p.Id, p.Title, p.CreatedAt }) .ToListAsync();
var hasNext = posts.Count > limit; if (hasNext) posts.RemoveAt(limit);
return Ok(new { data = posts, hasNext }); }
[HttpGet("{id}")] public async Task
[HttpPost] public async Task
var post = new Post { Title = request.Title, Content = request.Content }; _db.Posts.Add(post); await _db.SaveChangesAsync();
return CreatedAtAction(nameof(GetPost), new { id = post.Id }, post); } } `
ControllerBase vs Controller
| Base Class | Use When |
|---|---|
| ControllerBase | Web API only (no view rendering) |
| Controller | MVC with views + API |
Model Binding
Binding Sources
| Attribute | Source | Example |
|---|---|---|
| [FromQuery] | Query string | ?id=123 |
| [FromRoute] | Route parameters | /api/users/123 |
| [FromBody] | Request body | JSON body |
| [FromHeader] | Request headers | Authorization: Bearer ... |
| [FromForm] | Form data | multipart/form-data |
| [FromServices] | DI container | [FromServices] ILogger log |
Binding Examples
`csharp // Query string binding app.MapGet("/api/posts", async ( [FromQuery] int page, [FromQuery] int limit, [FromQuery] string? search) => { ... });
// Route binding app.MapGet("/api/posts/{id}", async ( [FromRoute] int id) => { ... });
// Header binding app.MapGet("/api/posts", async ( [FromHeader(Name = "Accept-Language")] string language) => { ... });
// Body binding (complex types) app.MapPost("/api/posts", async ( [FromBody] CreatePostRequest request) => { ... });
// Multiple sources app.MapPut("/api/posts/{id}", async ( [FromRoute] int id, [FromBody] UpdatePostRequest request) => { ... }); `
Complex Model Binding
csharp public record CreatePostRequest( [Required, MinLength(5), MaxLength(200)] string Title, [Required, MinLength(10)] string Content, [Required] int AuthorId, [Required] List<int> TagIds, [FromQuery] bool IsDraft = false );
Validation
Built-in Validation (Data Annotations)
`csharp public class CreatePostRequest { [Required, MinLength(5), MaxLength(200)] public string Title { get; set; } = string.Empty;
[Required, MinLength(10)] public string Content { get; set; } = string.Empty;
[Required] public int AuthorId { get; set; } } `
FluentValidation Integration
`csharp // Register validators builder.Services.AddValidatorsFromAssemblyContaining
// Create validator public class CreatePostRequestValidator : AbstractValidator
RuleFor(x => x.Content) .NotEmpty().WithMessage("Content is required") .MinimumLength(10).WithMessage("Content must be at least 10 characters");
RuleFor(x => x.AuthorId) .GreaterThan(0).WithMessage("Author must be valid");
RuleFor(x => x.TagIds) .NotEmpty().WithMessage("At least one tag is required"); } }
// Use in Minimal API app.MapPost("/api/posts", async ( CreatePostRequest request, IValidator
// ... create post }); `
Custom Validators
`csharp public class UniqueTitleValidator : PropertyValidator
public UniqueTitleValidator(ApplicationDb db) { _db = db; }
protected override bool IsValid(PropertyValidatorContext context) { var title = context.PropertyValue; return !_db.Posts.Any(p => p.Title.ToLower() == title.ToLower()); } }
// Or with FluentValidation async public class UniqueSlugValidator : IAsyncValidator
public UniqueSlugValidator(ApplicationDb db) { _db = db; }
public async Task
Filters
Action Filter
`csharp public class LogExecutionTimeFilter : IAsyncActionFilter { private readonly ILogger
public async Task OnActionExecutionAsync( ActionExecutingContext context, ActionExecutionDelegate next) { _sw.Restart(); var executed = await next(); _sw.Stop();
_logger.LogInformation( "Action {Action} executed in {Elapsed}ms", executed.ActionDescriptor.DisplayName, _sw.ElapsedMilliseconds); } }
// Register builder.Services.AddControllers(options => options.Filters.Add
Exception Filter
`csharp public class GlobalExceptionFilter : IExceptionFilter { private readonly ILogger
public GlobalExceptionFilter(ILogger
public void OnException(ExceptionContext context) { _logger.LogError(context.Exception, "Unhandled exception");
context.Result = new InternalServerErrorObjectResult(context.Exception.Message); context.ExceptionHandled = true; } } `
IExceptionHandler (.NET 9+)
`csharp public class GlobalExceptionHandler : IExceptionHandler { private readonly ILogger
public GlobalExceptionHandler(ILogger
public async ValueTask
var problemDetails = exception switch { NotFoundException => new ProblemDetails { Status = 404, Title = "Resource not found", Detail = exception.Message }, ValidationException vex => new ValidationProblemDetails { Status = 400, Title = "Validation error", Errors = vex.Errors.ToDictionary(e => e.PropertyName, e => new[] { e.Message }) }, _ => new ProblemDetails { Status = 500, Title = "An unexpected error occurred", Type = "https://httpstatuses.com/500" } };
httpContext.Response.StatusCode = problemDetails.Status.Value; await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true; } }
// Register builder.Services.AddExceptionHandler
Middleware Pipeline
Pipeline Order
`csharp var app = builder.Build();
// 1. Exception handling (must be early) app.UseExceptionHandler();
// 2. HTTP Strict Transport Security if (!app.Environment.IsDevelopment()) app.UseHsts();
// 3. HTTPS redirect app.UseHttpsRedirection();
// 4. Routing (required for UseRouting) app.UseRouting();
// 5. Authentication app.UseAuthentication();
// 6. Authorization app.UseAuthorization();
// 7. Response compression app.UseResponseCompression();
// 8. Custom middleware app.UseRequestLogging();
// 9. Endpoints app.MapControllers(); app.MapGroup("/api").MapApiEndpoints();
app.Run(); `
Short-Circuiting
`csharp public class CacheMiddleware { private readonly RequestDelegate _next; private readonly IDistributedCache _cache;
public CacheMiddleware(RequestDelegate next, IDistributedCache cache) { _next = next; _cache = cache; }
public async Task InvokeAsync(HttpContext context) { if (context.Request.Method != "GET") { await _next(context); return; }
var cacheKey = context.Request.Path; var cached = await _cache.GetStringAsync(cacheKey);
if (cached is not null) { context.Response.Headers.Cache-Control = "public, max-age=60"; await context.Response.WriteAsync(cached); return; // Short-circuit }
// Store original body var originalBody = context.Response.Body; using var memoryStream = new MemoryStream(); context.Response.Body = memoryStream;
await _next(context); context.Response.Body = originalBody;
memoryStream.Seek(0, SeekOrigin.Begin); var response = await new StreamReader(memoryStream).ReadToEndAsync();
await _cache.SetStringAsync(cacheKey, response, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1) });
await context.Response.WriteAsync(response); } } `
Custom Request/Response Logging Middleware
`csharp public class RequestResponseLoggingMiddleware { private readonly RequestDelegate _next; private readonly ILogger
public RequestResponseLoggingMiddleware(RequestDelegate next, ILogger
public async Task InvokeAsync(HttpContext context) { // Log request var requestStart = DateTime.UtcNow; _logger.LogInformation( "{Method} {Path} from {IP}", context.Request.Method, context.Request.Path, context.Connection.RemoteIpAddress);
// Capture response body var originalBodyStream = context.Response.Body; using var responseBody = new MemoryStream(); context.Response.Body = responseBody;
try { await _next(context); } catch (Exception ex) { _logger.LogError(ex, "Request failed: {Method} {Path}", context.Request.Method, context.Request.Path); throw; } finally { // Log response var duration = DateTime.UtcNow - requestStart; responseBody.Seek(0, SeekOrigin.Begin); var responseText = await new StreamReader(responseBody).ReadToEndAsync();
_logger.LogInformation( "{Method} {Path} -> {StatusCode} in {Elapsed}ms | {Body}", context.Request.Method, context.Request.Path, context.Response.StatusCode, duration.TotalMilliseconds, string.IsNullOrWhiteSpace(responseText) ? "(empty)" : responseText[..Math.Min(200, responseText.Length)]);
responseBody.Seek(0, SeekOrigin.Begin); await responseBody.CopyToAsync(originalBodyStream); } } }
// Extension method public static class RequestResponseLoggingExtensions { public static IApplicationBuilder UseRequestResponseLogging( this IApplicationBuilder builder) { return builder.UseMiddleware
Practice Tasks
Minimal API with DI
`csharp // Program.cs var builder = WebApplication.CreateBuilder(args); builder.Services.AddScoped
var app = builder.Build();
app.MapPost("/api/notifications/email", async ( SendEmailRequest request, IEmailService emailService) => { await emailService.SendAsync(request.To, request.Subject, request.Body); return Results.Ok(new { sent = true }); });
app.Run(); `
Validation Middleware with Detailed Error Responses
`csharp public class ValidationMiddleware { private readonly RequestDelegate _next;
public ValidationMiddleware(RequestDelegate next) { _next = next; }
public async Task InvokeAsync(HttpContext context, IValidatorFactory validatorFactory) { if (context.Request.Body.Length > 0 && context.Request.ContentType?.Contains("application/json") == true) { var body = await new StreamReader(context.Request.Body).ReadToEndAsync(); context.Request.Body.Position = 0;
try { var obj = JsonSerializer.Deserialize
await _next(context); } } `
Custom Middleware for Request/Response Logging
See RequestResponseLoggingMiddleware above — register with pp.UseRequestResponseLogging().
References
- [ASP.NET Core Web API docs](https://learn.microsoft.com/en-us/aspnet/core/web-api/)
- [Minimal APIs in ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis)
- [FluentValidation for ASP.NET Core](https://fluentvalidation.net/)
- [RFC 9457 - Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc9457)
Практика
OpenAPI / Swagger
# OpenAPI / Swagger
OpenAPI 3.0/3.1 Specification
Structure
openapi: 3.1.0
info:
title: Blog Platform API
description: REST API for blog platform
version: 1.0.0
contact:
name: API Support
email: support@example.com
license:
name: MIT
url: https://opensource.org/licenses/MIT
servers:
- url: https://api.example.com/v1
description: Production
- url: https://staging-api.example.com/v1
description: Staging
paths:
/posts:
get:
summary: List posts
operationId: getPosts
tags: [Posts]
parameters:
- name: limit
in: query
schema:
type: integer
default: 20
maximum: 100
- name: cursor
in: query
schema:
type: string
responses:
"200":
description: Success
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
ref_yaml: "#/components/schemas/Post"
pagination:
ref_yaml: "#/components/schemas/Pagination"
"400":
description: Bad request
content:
application/problem+json:
schema:
ref_yaml: "#/components/schemas/ProblemDetails"
components:
schemas:
Post:
type: object
required: [id, title, createdAt]
properties:
id:
type: integer
format: int32
title:
type: string
minLength: 5
maxLength: 200
content:
type: string
createdAt:
type: string
format: date-time
Pagination:
type: object
properties:
hasNextPage:
type: boolean
nextCursor:
type: string
ProblemDetails:
type: object
properties:
type:
type: string
title:
type: string
status:
type: integer
detail:
type: string
instance:
type: string
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT> Note: In actual YAML, replace ref_yaml: with $ref: — the $ is escaped here for PowerShell compatibility.
Key Components
| Component | Purpose |
|---|---|
info | API metadata (title, version, contact, license) |
servers | Base URLs for different environments |
paths | All API endpoints with operations |
components | Reusable schemas, security schemes, parameters |
security | Global security requirements |
Swashbuckle — Automatic Documentation
Setup
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo
{
Title = "Blog Platform API",
Version = "v1",
Description = "REST API for blog platform",
Contact = new OpenApiContact
{
Name = "API Support",
Email = "support@example.com"
}
});
// XML comments
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
c.IncludeXmlComments(xmlPath);
// Security scheme
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
Description = "JWT Authorization header"
});
c.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" }
},
Array.Empty<string>()
}
});
});
var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI(c =>
{
c.SwaggerEndpoint("/swagger/v1/swagger.json", "Blog Platform API v1");
c.RoutePrefix = "swagger";
});Custom Document Filters
public class AddVersionHeaderFilter : IDocumentFilter
{
public void Apply(OpenApiDocument doc, DocumentFilterContext context)
{
doc.Info.Description += "\n\n**Note:** All API requests require the `X-API-Version` header.";
// Add global parameter
foreach (var path in doc.Paths)
{
foreach (var operation in path.Value.Operations)
{
operation.Value.Parameters ??= new List<OpenApiParameter>();
operation.Value.Parameters.Add(new OpenApiParameter
{
Name = "X-API-Version",
In = ParameterLocation.Header,
Required = true,
Schema = new OpenApiSchema { Type = "string", Default = "v1" }
});
}
}
}
}
// Register
builder.Services.AddSwaggerGen(c =>
{
c.DocumentFilter<AddVersionHeaderFilter>();
});Custom Operation Filters
public class RequireAuthorizationFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
var hasAuthorize = context.MethodInfo.DeclaringType != null &&
(context.MethodInfo.DeclaringType.GetCustomAttributes(true).Any() ||
context.MethodInfo.GetCustomAttributes(true).Any());
if (hasAuthorize)
{
operation.Security.Add(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" }
},
Array.Empty<string>()
}
});
}
// Add standard error responses
operation.Responses.TryAdd("400", new OpenApiResponse { Description = "Bad Request" });
operation.Responses.TryAdd("401", new OpenApiResponse { Description = "Unauthorized" });
operation.Responses.TryAdd("404", new OpenApiResponse { Description = "Not Found" });
operation.Responses.TryAdd("500", new OpenApiResponse { Description = "Internal Server Error" });
}
}API Versioning
Strategy Comparison
| Strategy | URL Example | Pros | Cons |
|---|---|---|---|
| URL Path | /api/v1/posts | Simple, cacheable, visible | URL changes |
| Query String | /api/posts?api-version=1 | Clean URLs | Cache issues |
| Header | API-Version: 1 | Clean URLs | Not visible in docs |
| Media Type | Accept: application/vnd.myapi.v1+json | RESTful | Complex |
URL Path Versioning (Recommended)
// Install: Microsoft.AspNetCore.Mvc.Versioning
builder.Services.AddApiVersioning(options =>
{
options.AssumeDefaultVersionWhenUnspecified = true;
options.DefaultApiVersion = new ApiVersion(1, 0);
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(),
new HeaderApiVersionReader("X-API-Version"),
new QueryStringApiVersionReader("api-version"));
}).AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
// Versioned Minimal APIs
var api = app.MapGroup("/api/v{version:apiVersion}/posts");
api.MapGet("/", GetPostsV1)
.MapToApiVersion(new ApiVersion(1, 0));
api.MapGet("/enhanced", GetPostsV2)
.MapToApiVersion(new ApiVersion(2, 0));Deprecation Middleware (RFC 8594)
public class DeprecationMiddleware
{
private readonly RequestDelegate _next;
public DeprecationMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
var apiVersion = context.GetApiVersion()?.ToString();
if (apiVersion == "1.0")
{
context.Response.Headers.Deprecation = "true";
context.Response.Headers.Sunset = "Sat, 31 Dec 2026 23:59:59 GMT";
context.Response.Headers.Link = "<https://api.example.com/v2/>; rel=\"successor-version\"";
context.Response.Headers.Link += ", <https://docs.example.com/migration/v1-to-v2>; rel=\"deprecation\"";
}
await _next(context);
}
}Schema Design
Reusable Models with Annotations
public class Post
{
[Required]
[OpenApiSchema(Description = "Unique identifier", Example = 123)]
public int Id { get; set; }
[Required, MinLength(5), MaxLength(200)]
[OpenApiSchema(Description = "Post title", Example = "Getting Started")]
public string Title { get; set; } = string.Empty;
[Required]
public string Content { get; set; } = string.Empty;
[OpenApiSchema(Format = "date-time")]
public DateTime CreatedAt { get; set; }
}Polymorphism with Discriminator
public abstract class Notification
{
public string Type { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
}
public class EmailNotification : Notification
{
public string Email { get; set; } = string.Empty;
}
public class SmsNotification : Notification
{
public string PhoneNumber { get; set; } = string.Empty;
}
// OpenAPI oneOf schema with discriminator
c.Map<Notification>(() => new OpenApiSchema
{
OneOf = new List<OpenApiSchema>
{
new() { Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = "EmailNotification" } },
new() { Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = "SmsNotification" } }
},
Discriminator = new OpenApiDiscriminator
{
PropertyName = "Type",
Mapping = new Dictionary<string, string>
{
["email"] = "EmailNotification",
["sms"] = "SmsNotification"
}
}
});NSwag — Spec-First Workflow
CLI Commands
# Install
dotnet tool install --global NSwag.ConsoleCore
# Generate spec from ASP.NET Core assembly
nswag aspnetcore2openapi \
/assembly:./bin/Debug/net8.0/MyApi.dll \
/output:openapi.json
# Generate C# client
nswag openapi2csclient \
/input:openapi.json \
/classname:BlogApiClient \
/namespace:MyApp.Services \
/output:BlogApiClient.cs \
/generateClientInterfaces:true \
/injectHttpClient:true
# Generate TypeScript client
nswag openapi2tsclient \
/input:openapi.json \
/output:src/api/blog-client.ts \
/template:Fetch
# Generate controller stubs (contract-first)
nswag openapi2cscontroller \
/input:openapi.json \
/ControllerTarget:AspNetCorenswag.json Configuration
{
"runtime": "Net80",
"defaultVariables": "Configuration=Debug",
"documentGenerator": {
"aspNetCoreToOpenApi": {
"project": "MyApi.csproj",
"output": "wwwroot/openapi.json",
"outputType": "OpenApi3"
}
},
"codeGenerators": {
"openApiToCSharpClient": {
"generateClientClasses": true,
"generateClientInterfaces": true,
"generateDtoTypes": true,
"injectHttpClient": true,
"disposeHttpClient": false,
"exceptionClass": "ApiException",
"className": "{controller}Client",
"namespace": "MyApp.Services",
"output": "Services/BlogApiClient.cs"
},
"openApiToTypeScriptClient": {
"className": "{controller}Client",
"template": "Fetch",
"generateClientInterfaces": true,
"generateDtoTypes": true,
"output": "src/api/generated-client.ts"
}
}
}Multiple API Versions with NSwag
services.AddApiVersioning(options =>
{
options.AssumeDefaultVersionWhenUnspecified = true;
options.ApiVersionReader = new UrlSegmentApiVersionReader();
}).AddMvcCore()
.AddVersionedApiExplorer(options =>
{
options.GroupNameFormat = "VVV";
options.SubstituteApiVersionInUrl = true;
});
services.AddOpenApiDocument(doc =>
{
doc.DocumentName = "v1";
doc.ApiGroupNames = new[] { "1" };
doc.Title = "My API v1";
});
services.AddOpenApiDocument(doc =>
{
doc.DocumentName = "v2";
doc.ApiGroupNames = new[] { "2" };
doc.Title = "My API v2";
});
app.UseOpenApi(); // /swagger/v1/swagger.json, /swagger/v2/swagger.json
app.UseSwaggerUI(); // single UI shows bothPractice Tasks
Swagger with Custom Document & Operation Filters
Implement IDocumentFilter and IOperationFilter as shown above.
API Versioning Strategy
Use Microsoft.AspNetCore.Mvc.Versioning with URL path versioning.
OpenAPI Spec-First with NSwag
- Write OpenAPI spec manually or generate from code
- Generate C# client:
nswag openapi2csclient - Generate TypeScript client:
nswag openapi2tsclient - Generate controller stubs for contract-first development
References
- [OpenAPI Specification 3.1](https://spec.openapis.org/oas/v3.1.0)
- [Swashbuckle ASP.NET Core](https://github.com/domaindrivendev/Swashbuckle.AspNetCore)
- [NSwag documentation](https://github.com/RicoSuter/NSwag)
- [RFC 8594 - Deprecation and Sunset Headers](https://www.rfc-editor.org/rfc/rfc8594)
- [API Versioning with ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/web-api/api-versioning)
Практика
gRPC
# gRPC
Protocol Buffers
Message Definition
Protocol Buffers (protobuf) is a serialization format from Google. ~3-10x smaller and ~20-100x faster than JSON.
syntax = "proto3";
package ordermanagement;
option csharp_namespace = "OrderManagement.Grpc";
message Order {
int32 id = 1;
string customer_email = 2;
repeated OrderItem items = 3;
OrderStatus status = 4;
Money total_amount = 5;
string shipping_address = 6;
google.protobuf.Timestamp created_at = 7;
google.protobuf.Timestamp updated_at = 8;
}
message OrderItem {
string product_id = 1;
string product_name = 2;
int32 quantity = 3;
Money unit_price = 4;
}
message Money {
int64 units = 1;
int32 nanos = 2;
string currency_code = 3;
}
enum OrderStatus {
ORDER_STATUS_UNSPECIFIED = 0;
PENDING = 1;
CONFIRMED = 2;
SHIPPED = 3;
DELIVERED = 4;
CANCELLED = 5;
}
import "google/protobuf/timestamp.proto";
import "google/protobuf/empty.proto";Field Numbering Rules
- Numbers 1-15 for frequently used fields (1 byte in wire format)
- Numbers 16-2045 for rarely used fields (2 bytes)
- DO NOT delete field numbers - this breaks compatibility
- Can mark removed fields as
reserved
message User {
reserved 7, 9;
reserved "foo", "bar";
int32 id = 1;
string name = 2;
string email = 3;
}gRPC Service Methods
Four Method Types
service OrderService {
// Unary - single request, single response
rpc CreateOrder(CreateOrderRequest) returns (Order);
// Server Streaming - single request, stream of responses
rpc GetOrderUpdates(OrderId) returns (stream OrderUpdate);
// Client Streaming - stream of requests, single response
rpc BatchCreateOrders(stream CreateOrderRequest) returns (BatchOrderResponse);
// Bidirectional Streaming - streams both directions
rpc TrackOrders(stream OrderQuery) returns (stream OrderTracking);
}
message OrderId { int32 order_id = 1; }
message CreateOrderRequest {
string customer_email = 1;
repeated OrderItem items = 2;
string shipping_address = 3;
}
message Order {
int32 id = 1;
string customer_email = 2;
OrderStatus status = 3;
google.protobuf.Timestamp created_at = 4;
}
message OrderUpdate {
int32 order_id = 1;
OrderStatus new_status = 2;
google.protobuf.Timestamp updated_at = 3;
}
message BatchOrderResponse {
repeated Order orders = 1;
}
message OrderQuery { int32 order_id = 1; }
message OrderTracking {
int32 order_id = 1;
string location = 2;
string status = 3;
google.protobuf.Timestamp timestamp = 4;
}Method Type Comparison
| Type | Request | Response | Use Case |
|---|---|---|---|
| Unary | 1 | 1 | CRUD operations, simple calls |
| Server Streaming | 1 | Many | Real-time updates, feeds |
| Client Streaming | Many | 1 | Batch operations, file upload |
| Bidirectional | Many | Many | Chat, live collaboration |
gRPC Interceptors
Server-Side Logging Interceptor
public class LoggingInterceptor : Interceptor
{
private readonly ILogger<LoggingInterceptor> _logger;
public LoggingInterceptor(ILogger<LoggingInterceptor> logger)
{
_logger = logger;
}
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
_logger.LogInformation(
"Unary call: {Method} | CorrelationId: {CorrelationId}",
context.Method,
context.GetCorrelationId());
var stopwatch = Stopwatch.StartNew();
var response = await continuation(request, context);
stopwatch.Stop();
_logger.LogInformation(
"Completed: {Method} in {Elapsed}ms",
context.Method,
stopwatch.ElapsedMilliseconds);
return response;
}
}
public static class ServerCallContextExtensions
{
public static string GetCorrelationId(this ServerCallContext context)
{
var header = context.RequestHeaders
.FirstOrDefault(h => h.Key == "x-correlation-id");
return header?.Value ?? Guid.NewGuid().ToString();
}
}Authentication Interceptor
public class AuthInterceptor : Interceptor
{
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
var authHeader = context.RequestHeaders
.FirstOrDefault(h => h.Key == "authorization")?.Value;
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer "))
{
throw new RpcException(
new Status(StatusCode.Unauthenticated, "Missing or invalid auth header"));
}
var token = authHeader.Substring("Bearer ".Length);
if (!await ValidateTokenAsync(token))
{
throw new RpcException(
new Status(StatusCode.Unauthenticated, "Invalid token"));
}
var user = await GetUserFromTokenAsync(token);
context.Items["UserId"] = user.Id;
context.Items["Roles"] = user.Roles;
return await continuation(request, context);
}
private async Task<bool> ValidateTokenAsync(string token) => true;
private async Task<User> GetUserFromTokenAsync(string token)
=> new User { Id = 1, Roles = new[] { "User", "Admin" } };
}
public record User(int Id, string[] Roles);Error Handling Interceptor
public class ErrorHandlingInterceptor : Interceptor
{
private readonly ILogger<ErrorHandlingInterceptor> _logger;
public ErrorHandlingInterceptor(ILogger<ErrorHandlingInterceptor> logger)
{
_logger = logger;
}
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
try
{
return await continuation(request, context);
}
catch (NotFoundException ex)
{
_logger.LogWarning(ex, "Not found: {Method}", context.Method);
throw new RpcException(new Status(StatusCode.NotFound, ex.Message));
}
catch (ValidationException ex)
{
_logger.LogWarning(ex, "Validation error: {Method}", context.Method);
throw new RpcException(new Status(StatusCode.InvalidArgument, "Validation failed"));
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error: {Method}", context.Method);
throw new RpcException(new Status(StatusCode.Internal, "An unexpected error occurred"));
}
}
}
public class NotFoundException : Exception
{
public NotFoundException(string message) : base(message) { }
}
public class ValidationException : Exception
{
public ValidationException(List<ValidationError> errors) { Errors = errors; }
public List<ValidationError> Errors { get; }
}
public class ValidationError
{
public string PropertyName { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
}Register Interceptors
builder.Services.AddGrpc(options =>
{
options.Interceptors.Add<LoggingInterceptor>();
options.Interceptors.Add<AuthInterceptor>();
options.Interceptors.Add<ErrorHandlingInterceptor>();
});gRPC vs REST Performance
Benchmark Comparison
| Metric | gRPC (protobuf) | REST (JSON) |
|---|---|---|
| Payload size | ~200 bytes | ~500 bytes |
| Serialization time | ~0.1ms | ~0.5ms |
| P95 latency (local) | 5-10ms | 20-50ms |
| P95 latency (network) | 10-20ms | 30-80ms |
| Throughput | 50,000+ req/s | 10,000-20,000 req/s |
| HTTP version | HTTP/2 | HTTP/1.1 or HTTP/2 |
| Multiplexing | Yes | Yes (HTTP/2) |
When to Use Each
| Scenario | Recommended |
|---|---|
| Internal microservice communication | gRPC |
| High-performance real-time updates | gRPC |
| Public API with web/mobile clients | REST |
| Browser-based clients | REST (or GraphQL) |
| File uploads/downloads | REST |
| Server streaming (feeds, updates) | gRPC |
| Complex query patterns | GraphQL |
HTTP/2 Multiplexing
HTTP/2 advantages for gRPC:
- Multiplexing: Multiple requests/responses over a single TCP connection
- Header compression: HPACK reduces overhead
- Binary framing: More efficient than text-based HTTP/1.1
- Server push: Server can send data without client request
- Prioritization: Streams can have priorities
// Configure Kestrel for HTTP/2
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenAnyIP(5001, listenOptions =>
{
listenOptions.Protocols = HttpProtocols.Http2;
listenOptions.UseHttps(); // HTTP/2 requires TLS
});
});Practice Tasks
Order Management Service (.proto)
See service OrderService section above - full contract for order management.
Bidirectional Streaming for Real-Time Updates
The TrackOrders method in proto: client sends OrderQuery, server streams OrderTracking.
public override async IAsyncEnumerable<OrderTracking> TrackOrders(
IAsyncEnumerable<OrderQuery> requestStream,
ServerCallContext context)
{
await foreach (var query in requestStream)
{
var tracking = await GetTrackingAsync(query.OrderId);
yield return tracking;
}
}gRPC vs REST Benchmark
Use BenchmarkDotNet:
[MemoryDiagnoser]
public class GrpcVsRestBenchmark
{
private GrpcClient _grpcClient;
private HttpClient _httpClient;
[GlobalSetup]
public void Setup()
{
_grpcClient = new GrpcClient(
new Channel("localhost:5001", ChannelCredentials.Insecure));
_httpClient = new HttpClient
{
BaseAddress = new Uri("http://localhost:5000")
};
}
[Benchmark]
public async Task<Order> Grpc_GetOrder()
{
return await _grpcClient.OrderService.GetOrderAsync(
new OrderId { OrderId = 1 });
}
[Benchmark]
public async Task<OrderDto> Rest_GetOrder()
{
var json = await _httpClient.GetStringAsync("/api/orders/1");
return JsonSerializer.Deserialize<OrderDto>(json);
}
}References
- [gRPC C# Quick Start](https://learn.microsoft.com/en-us/aspnet/core/grpc/)
- [Protocol Buffers Language Guide](https://protobuf.dev/programming-guides/proto3/)
- [gRPC Concepts](https://grpc.io/docs/what-is-grpc/core-concepts/)
- [ASP.NET Core gRPC tutorial](https://learn.microsoft.com/en-us/aspnet/core/tutorials/grpc/)
Практика
GraphQL
# GraphQL
Schema Definition
Types, Queries, Mutations, Subscriptions
GraphQL schema defines the contract between client and server. It uses Schema Definition Language (SDL) or code-first approach.
# Schema Definition Language (SDL)
type Query {
posts(first: Int, after: String): PostConnection!
post(id: ID!): Post
comments(postId: ID!, first: Int): CommentConnection!
}
type Mutation {
createPost(input: CreatePostInput!): CreatePostPayload!
updatePost(input: UpdatePostInput!): UpdatePostPayload!
deletePost(id: ID!): DeletePayload!
}
type Subscription {
postCreated: Post!
postUpdated(id: ID!): Post!
commentAdded(postId: ID!): Comment!
}
type Post {
id: ID!
title: String!
content: String!
slug: String!
author: User!
comments: [Comment!]!
tags: [Tag!]!
createdAt: DateTime!
updatedAt: DateTime!
}
type User {
id: ID!
name: String!
email: String!
avatar: String
posts(first: Int): [Post!]!
}
input CreatePostInput {
title: String!
content: String!
tagIds: [ID!]
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}Code-First Approach (Hot Chocolate)
// Type definition
public class PostType : ObjectType<Post>
{
protected override void Configure(IObjectTypeDescriptor<Post> descriptor)
{
descriptor
.Description("A blog post")
.Implements<NodeType<Post>>(); // Relay-compatible
descriptor.Field(p => p.Id).Description("Unique identifier");
descriptor.Field(p => p.Title).Description("Post title");
descriptor.Field(p => p.Content).Description("Post body");
descriptor.Field(p => p.Author).ResolveWith<PostResolvers>(r => r.ResolveAuthor(default!));
descriptor.Field(p => p.CreatedAt).Type<NonNullType<DateTimeType>>();
}
}
// Query type
public class QueryType
{
[UsePaging<Post>(MaxPageSize = 50, IncludeTotalCount = true)]
[UseProjection]
[UseFiltering]
[UseSorting]
public IQueryable<Post> GetPosts([Service] ApplicationDb db)
=> db.Posts.Include(p => p.Author);
public Post? GetPost([Service] ApplicationDb db, [Required] int id)
=> db.Posts.Include(p => p.Author).FirstOrDefault(p => p.Id == id);
}
// Mutation type
public class MutationType
{
public async Task<CreatePostPayload> CreatePostAsync(
[Service] ApplicationDb db,
[Service] IValidator<CreatePostInput> validator,
CreatePostInput input,
CancellationToken ct)
{
var result = await validator.ValidateAsync(input, ct);
if (!result.IsValid)
return CreatePostPayload.WithValidationErrors(result.ToErrors());
var post = new Post
{
Title = input.Title,
Content = input.Content,
Slug = input.Title.ToSlug(),
AuthorId = 1 // from auth context
};
if (input.TagIds != null)
{
foreach (var tagId in input.TagIds)
{
var tag = await db.Tags.FindAsync(tagId, ct);
if (tag != null) post.Tags.Add(tag);
}
}
db.Posts.Add(post);
await db.SaveChangesAsync(ct);
return CreatePostPayload.WithPost(post);
}
}Registration
builder.Services
.AddGraphQLServer()
.AddQueryType<QueryType>()
.AddMutationType<MutationType>()
.AddSubscriptionType<SubscriptionType>()
.AddType<PostType>()
.AddType<UserType>()
.AddType<TagType>()
.AddFiltering()
.AddSorting()
.AddProjections()
.AddDataLoaderRegistry()
.AddMaxExecutionDepthRule(10)
.AddComplexityAnalysis();Resolver Pattern
Data Fetching Strategies
public class PostResolvers
{
// Resolve with DataLoader (batching)
public async Task<User> ResolveAuthorAsync(int postId, PostByIdDataLoader postLoader)
{
var post = await postLoader.LoadAsync(postId);
return post?.Author;
}
// Direct resolver
public async Task<IReadOnlyList<Comment>> ResolveCommentsAsync(
Post post,
[Service] ApplicationDb db,
CancellationToken ct)
{
return await db.Comments
.Where(c => c.PostId == post.Id)
.OrderByDescending(c => c.CreatedAt)
.ToListAsync(ct);
}
}Resolver Context
public class Resolvers
{
public async Task<Post> GetPostAsync(
[Service] ApplicationDb db,
[Service] IDocumentContext context,
int id)
{
// Access document context for query info
var query = context.Request.Query;
var selections = context.SelectionSet.Selections;
return await db.Posts
.Include(p => p.Author)
.FirstOrDefaultAsync(p => p.Id == id);
}
}N+1 Problem & DataLoader
The N+1 Problem
# Client query
{
posts(first: 10) {
edges {
node {
title
author {
name
avatar
}
comments {
text
author {
name
}
}
}
}
}
}Without DataLoader, this generates:
- 1 query for posts
- 10 queries for authors (one per post)
- N queries for comments (one per post)
- M queries for comment authors
Total: 1 + 10 + N + M queries (N+1 problem)
DataLoader Solution
// Batch DataLoader for Posts
public class PostByIdDataLoader : BatchDataLoader<int, Post>
{
private readonly IDbContextFactory<ApplicationDb> _dbFactory;
public PostByIdDataLoader(IDbContextFactory<ApplicationDb> dbFactory)
: base(dbFactory) { }
protected override async Task<IReadOnlyDictionary<int, Post>> LoadBatchAsync(
IReadOnlyList<int> keys,
CancellationToken cancellationToken)
{
using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
return await db.Posts
.AsNoTracking()
.Include(p => p.Author)
.Where(p => keys.Contains(p.Id))
.ToDictionaryAsync(p => p.Id, cancellationToken);
}
}
// Group DataLoader for Comments (one post -> many comments)
public class CommentsByPostIdDataLoader : GroupDataLoader<int>
{
private readonly IDbContextFactory<ApplicationDb> _dbFactory;
public CommentsByPostIdDataLoader(IDbContextFactory<ApplicationDb> dbFactory)
: base(dbFactory) { }
protected override async Task<ILookup<int, Comment>> LoadBatchAsync(
IReadOnlyList<int> postIds,
CancellationToken cancellationToken)
{
using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
return await db.Comments
.AsNoTracking()
.Include(c => c.Author)
.Where(c => postIds.Contains(c.PostId))
.ToLookupAsync(c => c.PostId, cancellationToken);
}
}
// Usage in resolver
public async Task<IReadOnlyList<Comment>> GetCommentsAsync(
Post post,
CommentsByPostIdDataLoader commentsLoader)
{
var comments = await commentsLoader.LoadAsync(post.Id);
return comments.ToList();
}Register DataLoaders
builder.Services.AddDataLoaderRegistry();
// Or explicitly:
builder.Services.AddSingleton<PostByIdDataLoader>();
builder.Services.AddSingleton<CommentsByPostIdDataLoader>();
builder.Services.AddSingleton<AuthorByIdDataLoader>();Query Complexity Analysis & Protection
Complexity Analysis
// Configure complexity analysis
builder.Services.AddGraphQLServer()
.AddComplexityAnalysis(options =>
{
options.MaxAllowedComplexity = 1000;
options.ThrowOnExceeded = true;
});
// Custom cost per field
builder.Services.Configure<ComplexityConfiguration>(options =>
{
options.FieldCosts["Post.author"] = 5;
options.FieldCosts["Post.comments"] = 10;
options.FieldCosts["Comment.author"] = 3;
});Depth Limiting
builder.Services.AddGraphQLServer()
.AddMaxExecutionDepthRule(10); // Max nesting depthQuery Cost Calculator Middleware
public class QueryCostMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<QueryCostMiddleware> _logger;
public QueryCostMiddleware(RequestDelegate next, ILogger<QueryCostMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
if (context.Request.Path.StartsWithSegments("/graphql") &&
context.Request.HasJsonContentType())
{
var body = await new StreamReader(context.Request.Body).ReadToEndAsync();
context.Request.Body.Position = 0;
var query = JsonSerializer.Deserialize<GraphQlRequest>(body);
if (query?.Query != null)
{
var cost = CalculateQueryCost(query.Query);
_logger.LogInformation("Query cost: {Cost} | Operation: {Operation}",
cost, query.OperationName);
if (cost > 5000)
{
context.Response.StatusCode = 422;
await context.Response.WriteAsJsonAsync(new
{
errors = new[]
{
new { message = "Query too complex", extensions = new { code = "QUERY_TOO_COMPLEX" } }
}
});
return;
}
}
}
await _next(context);
}
private int CalculateQueryCost(string query)
{
// Simple heuristic: count field occurrences, multiply by depth
var lines = query.Split('\n');
var depth = 0;
var maxDepth = 0;
var fieldCount = 0;
foreach (var line in lines)
{
var trimmed = line.Trim();
if (trimmed.StartsWith('{')) depth++;
if (trimmed.EndsWith('}')) depth--;
maxDepth = Math.Max(maxDepth, depth);
if (!string.IsNullOrEmpty(trimmed) &&
!trimmed.StartsWith('{') && !trimmed.EndsWith('}') &&
!trimmed.StartsWith('#') && !trimmed.Contains(':') == false)
{
fieldCount++;
}
}
return fieldCount * maxDepth;
}
}
public record GraphQlRequest(string? Query, string? OperationName, object? Variables);Federation
Subgraph (Hot Chocolate)
// Product subgraph
builder.Services.AddGraphQLServer()
.AddQueryType<ProductQuery>()
.AddFederatedQueryType() // Federation-aware query type
.AddFederatedType<Product>()
.AddFederatedEntity();
// Product entity with key
public class ProductType : FederatedEntityType<Product>
{
protected override object GetEntityKey(Product product) => product.Id;
}Gateway (Supergraph)
builder.Services.AddGraphQLServer()
.AddQueryType<Query>()
.AddRemoteSchema(
name: "products",
url: new Uri("http://products-service:4000/graphql"))
.AddRemoteSchema(
name: "orders",
url: new Uri("http://orders-service:4001/graphql"))
.AddRemoteSchema(
name: "users",
url: new Uri("http://users-service:4002/graphql"));Federation Schema Stitching
# Gateway query type
type Query {
_entities(representations: [_Any!]!): [_Entity]!
products: [Product!]!
orders: [Order!]!
user(id: ID!): User
}Subscriptions (Real-time)
Server-Side
public class SubscriptionType
{
private readonly Channel<Post> _postCreatedChannel;
private readonly Channel<Comment> _commentAddedChannel;
public SubscriptionType()
{
_postCreatedChannel = Channel.CreateUnbounded<Post>();
_commentAddedChannel = Channel.CreateUnbounded<Comment>();
}
[Subscribe]
[Topic("PostCreated")]
public async IAsyncEnumerable<Post> PostCreated([EventMessage] Post post)
{
yield return post;
}
[Subscribe]
[Topic("CommentAdded")]
public async IAsyncEnumerable<Comment> CommentAdded(
[Service] ApplicationDb db,
int postId)
{
// Real-time comment stream for a specific post
await foreach (var comment in GetCommentStream(postId))
{
yield return comment;
}
}
}
// Emit events
public class CommentService
{
private readonly ITopicPublisher _publisher;
public async Task AddCommentAsync(int postId, Comment comment)
{
// Save comment
await _db.Comments.AddAsync(comment);
await _db.SaveChangesAsync();
// Emit subscription event
await _publisher.PublishAsync("CommentAdded", postId, comment);
}
}Client-Side (Subscription)
subscription OnCommentAdded($postId: ID!) {
commentAdded(postId: $postId) {
id
text
author {
name
}
createdAt
}
}Practice Tasks
E-Commerce GraphQL Schema
Design a schema for an e-commerce platform with:
- Products (with categories, variants, reviews)
- Orders (line items, payments, shipping)
- Users (profiles, addresses, favorites)
- Search (full-text, filters, sorting)
type Query {
searchProducts(query: String!, filters: ProductFilters): ProductSearchResult!
product(id: ID!): Product
category(slug: String!): Category
orders(first: Int, after: String): OrderConnection!
}
type Mutation {
addToCart(input: AddToCartInput!): CartPayload!
checkout(input: CheckoutInput!): OrderPayload!
cancelOrder(id: ID!): CancelOrderPayload!
}
input ProductFilters {
categoryId: ID
minPrice: Float
maxPrice: Float
inStock: Boolean
sortBy: ProductSortField
}DataLoader for Batching Database Queries
Implement DataLoaders for:
ProductByIdDataLoader-- batch product lookupsReviewsByProductIdDataLoader-- batch reviews per productUserByIdDataLoader-- batch user lookups
Query Depth Limiter
Implement query depth limiting to prevent abusive queries:
- Configure max depth via
AddMaxExecutionDepthRule(10) - Add custom cost analysis middleware
- Return meaningful errors when limits are exceeded
References
- [Hot Chocolate Documentation](https://chillicream.com/docs/hotchocolate)
- [GraphQL Specification](https://graphql.org/spec/)
- [Relay Cursor Connections Specification](https://relay.dev/graphql/connections.htm)
- [Apollo Federation Specification](https://www.apollographql.com/docs/federation/)
- [GreenDonut (DataLoader for .NET)](https://github.com/ChilliCream/GreenDonut)
Практика
API Gateway Patterns
# API Gateway Patterns
YARP (Yet Another Reverse Proxy)
Overview
YARP (Yet Another Reverse Proxy) is a high-performance reverse proxy toolkit for .NET, built on ASP.NET Core. It provides routing, load balancing, health checks, and request/response transformation capabilities.
Installation
dotnet add package Yarp.ReverseProxyBasic Configuration
// Program.cs
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
var app = builder.Build();
app.MapReverseProxy();Configuration via appsettings.json
{
"ReverseProxy": {
"Routes": {
"blog-api": {
"ClusterId": "blog-cluster",
"Match": {
"Path": "/api/blog/{**remainder}"
},
"Transforms": [
{ "PathRemovePrefix": "/api/blog" },
{ "PathAppend": true }
],
"RateLimiterPolicy": "blog-limit"
},
"order-api": {
"ClusterId": "order-cluster",
"Match": {
"Path": "/api/orders/{**remainder}"
},
"Transforms": [
{ "PathRemovePrefix": "/api/orders" }
]
},
"graphql": {
"ClusterId": "graphql-cluster",
"Match": {
"Path": "/graphql"
}
}
},
"Clusters": {
"blog-cluster": {
"Destinations": {
"destination1": {
"Address": "http://blog-service:5001"
},
"destination2": {
"Address": "http://blog-service:5002"
}
},
"HealthCheck": {
"Active": {
"Enabled": true,
"Interval": "00:00:10",
"Timeout": "00:00:05",
"Path": "/health"
}
},
"LoadBalancingPolicy": "round-robin"
},
"order-cluster": {
"Destinations": {
"destination1": {
"Address": "http://order-service:5003"
}
}
},
"graphql-cluster": {
"Destinations": {
"destination1": {
"Address": "http://graphql-service:5004"
}
}
}
}
}
}Routing
Path Matching
{
"ReverseProxy": {
"Routes": {
"exact-match": {
"ClusterId": "exact-cluster",
"Match": { "Path": "/api/exact" }
},
"prefix-match": {
"ClusterId": "prefix-cluster",
"Match": { "Path": "/api/prefix/{**remainder}" }
},
"wildcard": {
"ClusterId": "wildcard-cluster",
"Match": { "Path": "*" }
},
"host-based": {
"ClusterId": "host-cluster",
"Match": {
"Path": "/api/{**remainder}",
"Hosts": [ "api.example.com", "v1.example.com" ]
}
},
"method-based": {
"ClusterId": "read-cluster",
"Match": {
"Path": "/api/data/{**remainder}",
"Methods": [ "GET", "HEAD" ]
}
},
"header-based": {
"ClusterId": "header-cluster",
"Match": {
"Path": "/api/{**remainder}",
"Headers": [
{ "Name": "X-Api-Version", "Values": [ "v1", "v2" ] }
]
}
}
}
}
}Programmatic Routing
builder.Services.AddReverseProxy()
.InitializeAsync(async (config) =>
{
config.routes.Add("api-route", new RouteConfig
{
ClusterId = "api-cluster",
Match = new RouteMatch
{
Path = "/api/{**remainder}"
},
Transforms = new[]
{
new PathRemovePrefixTransform("/api"),
new PathAppendTransform(true)
}
});
config.clusters.Add("api-cluster", new ClusterConfig
{
Destinations = new Dictionary<string, DestinationConfig>
{
["dest1"] = new DestinationConfig { Address = "http://api1:5000" },
["dest2"] = new DestinationConfig { Address = "http://api2:5000" }
}
});
});Load Balancing
Strategies
| Strategy | Description | Use Case |
|---|---|---|
round-robin | Distribute evenly across healthy destinations | General purpose |
least-requests | Send to destination with fewest active requests | Variable response times |
random | Random selection | Simple distribution |
power-of-two-choices | Pick two random, choose least loaded | High throughput |
first | First healthy destination | Single-primary setups |
Configuration
{
"ReverseProxy": {
"Clusters": {
"high-traffic-cluster": {
"LoadBalancingPolicy": "power-of-two-choices",
"Destinations": { ... }
},
"variable-latency-cluster": {
"LoadBalancingPolicy": "least-requests",
"Destinations": { ... }
}
}
}
}Health-Based Load Balancing
{
"ReverseProxy": {
"Clusters": {
"health-checked-cluster": {
"LoadBalancingPolicy": "round-robin",
"HealthCheck": {
"Active": {
"Enabled": true,
"Interval": "00:00:10",
"Timeout": "00:00:05",
"Path": "/health",
"Policy": "consecutive-errors",
"HostHeader": "health-check"
},
"Passive": {
"Enabled": true,
"Policy": "failures",
"Interval": "00:00:30",
"Timeout": "00:00:05"
}
},
"Destinations": {
"dest1": {
"Address": "http://service1:5000",
"Health": { "Active": { "Host": "service1" } }
},
"dest2": {
"Address": "http://service2:5000",
"Health": { "Active": { "Host": "service2" } }
}
}
}
}
}
}Active Health Check
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.ConfigureHttpClient((context, client) =>
{
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Add("X-Proxy", "YARP");
});Request/Response Transformation
Transforms
{
"ReverseProxy": {
"Routes": {
"transformed": {
"ClusterId": "backend-cluster",
"Match": { "Path": "/api/v1/{**remainder}" },
"Transforms": [
{ "PathRemovePrefix": "/api/v1" },
{ "PathAppend": true },
{ "RequestHeader": { "Set": [ { "Name": "X-Forwarded-For", "Value": "{ClientIpAddress}" } ] } },
{ "RequestHeader": { "Set": [ { "Name": "X-Proxy-Request", "Value": "true" } ] } },
{ "ResponseHeader": { "Set": [ { "Name": "X-Proxy", "Value": "YARP" } ] } },
{ "ResponseHeader": { "Delete": [ "Server" ] } }
]
}
}
}
}Protocol Translation
// Custom transform for gRPC-to-HTTP translation
public class GrpcToHttpTransform : IEndpointFilter
{
private readonly IGrpcChannelFactory _channelFactory;
public GrpcToHttpTransform(IGrpcChannelFactory channelFactory)
{
_channelFactory = channelFactory;
}
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
var grpcChannel = _channelFactory.GetChannel("order-service");
var orderClient = new Order.OrderClient(grpcChannel);
// Translate HTTP request to gRPC call
var httpRequest = context.Arguments[0] as HttpRequest;
var orderRequest = DeserializeFromHttp(httpRequest);
var response = await orderClient.CreateOrderAsync(orderRequest);
return SerializeToHttp(response);
}
private OrderRequest DeserializeFromHttp(HttpRequest request) => throw new NotImplementedException();
private HttpResponse SerializeToHttp(OrderResponse response) => throw new NotImplementedException();
}Payload Shaping
public class PayloadShapingMiddleware
{
private readonly RequestDelegate _next;
public PayloadShapingMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
var originalBody = context.Response.Body;
using var responseBody = new MemoryStream();
context.Response.Body = responseBody;
await _next(context);
responseBody.Seek(0, SeekOrigin.Begin);
var responseText = await new StreamReader(responseBody).ReadToEndAsync();
// Shape response for mobile clients
if (context.Request.Headers["X-Client-Type"] == "mobile")
{
var shaped = ShapeForMobile(responseText);
await context.Response.WriteAsync(shaped);
}
else
{
responseBody.CopyTo(originalBody);
}
}
private string ShapeForMobile(string jsonResponse)
{
// Remove expensive fields for mobile
var json = JsonSerializer.Deserialize<JsonElement>(jsonResponse);
// ... shape the response
return JsonSerializer.Serialize(json);
}
}API Composition
Aggregating Multiple Services
// Composition service that aggregates data from multiple backends
public class DashboardCompositionService
{
private readonly HttpClient _httpClient;
private readonly ILogger<DashboardCompositionService> _logger;
public DashboardCompositionService(IHttpClientFactory factory, ILogger<DashboardCompositionService> logger)
{
_httpClient = factory.CreateClient("composition");
_logger = logger;
}
public async Task<DashboardData> GetDashboardAsync(string userId)
{
// Parallel requests to multiple services
var userTask = _httpClient.GetFromJsonAsync<UserDto>($"http://user-service/api/users/{userId}");
var ordersTask = _httpClient.GetFromJsonAsync<OrderDto[]>($"http://order-service/api/orders?userId={userId}");
var analyticsTask = _httpClient.GetFromJsonAsync<AnalyticsDto>($"http://analytics-service/api/analytics/user/{userId}");
await Task.WhenAll(userTask, ordersTask, analyticsTask);
return new DashboardData
{
User = userTask.Result,
RecentOrders = ordersTask.Result?.Take(5).ToArray() ?? [],
Analytics = analyticsTask.Result,
GeneratedAt = DateTime.UtcNow
};
}
}
// YARP route for composition
builder.Services.AddReverseProxy()
.InitializeAsync(async config =>
{
config.routes.Add("dashboard", new RouteConfig
{
ClusterId = "composition-cluster",
Match = new RouteMatch { Path = "/api/dashboard/{**remainder}" }
});
config.clusters.Add("composition-cluster", new ClusterConfig
{
Destinations = new Dictionary<string, DestinationConfig>
{
["composition-service"] = new DestinationConfig { Address = "http://composition-service:5005" }
}
});
});Composition Endpoint
// Minimal API for dashboard composition
app.MapGroup("/api/composition").RequireAuthorization()
.MapGet("/dashboard", async (
string userId,
DashboardCompositionService compositionService) =>
{
try
{
var data = await compositionService.GetDashboardAsync(userId);
return Results.Ok(data);
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "Service call failed for user {UserId}", userId);
// Return partial data
return Results.Problem(
detail: "Some services are unavailable, returning partial data",
statusCode: 207); // Multi-Status
}
});BFF (Backend for Frontend) Pattern
BFF Architecture
+-----------+
| Web |
| Client |
+-----+-----+
|
+-----v-----+
| BFF |
| Layer |
+-----+-----+
|
+-----------+-----------+
| | |
+-----v---+ +-----v---+ +-----v---+
| REST | | gRPC | | GraphQL |
| API | | Service | | API |
+---------+ +---------+ +-------+BFF Implementation
// Mobile BFF
public class MobileBffEndpoints
{
public static void MapMobileBff(WebApplication app)
{
var bff = app.MapGroup("/bff/mobile").RequireAuthorization();
// Aggregated dashboard for mobile
bff.MapGet("/dashboard", async (HttpContext ctx, HttpClient httpClient) =>
{
var userId = ctx.GetUserId();
// Fetch and shape data specifically for mobile
var user = await httpClient.GetFromJsonAsync<UserDto>($"http://user-service/api/users/{userId}");
var recentOrders = await httpClient.GetFromJsonAsync<OrderSummaryDto[]>($"http://order-service/api/orders/{userId}/recent?count=5");
var notifications = await httpClient.GetFromJsonAsync<NotificationDto[]>($"http://notification-service/api/notifications/{userId}?unread=true");
return Results.Ok(new
{
user = new { user.Id, user.Name, user.Avatar }, // Minimal user data
recentOrders,
notifications,
summary = new
{
unreadCount = notifications?.Length ?? 0,
recentOrderCount = recentOrders?.Length ?? 0
}
});
});
// Optimized product listing for mobile
bff.MapGet("/products", async (HttpContext ctx, HttpClient httpClient,
[FromQuery] int page = 1, [FromQuery] int pageSize = 20) =>
{
var userId = ctx.GetUserId();
var products = await httpClient.GetFromJsonAsync<PaginatedResponse<ProductDto>>(
$"http://product-service/api/products?page={page}&pageSize={pageSize}&userId={userId}");
return Results.Ok(new
{
data = products?.Items?.Select(p => new
{
p.Id,
p.Name,
p.Price,
p.ImageUrl,
p.InStock
}),
pagination = products?.Pagination
});
});
}
}Desktop BFF
// Desktop BFF - more data-rich responses
public class DesktopBffEndpoints
{
public static void MapDesktopBff(WebApplication app)
{
var bff = app.MapGroup("/bff/desktop").RequireAuthorization();
bff.MapGet("/dashboard", async (HttpContext ctx, HttpClient httpClient) =>
{
var userId = ctx.GetUserId();
// Full data for desktop clients
var user = await httpClient.GetFromJsonAsync<UserDto>($"http://user-service/api/users/{userId}");
var allOrders = await httpClient.GetFromJsonAsync<OrderDto[]>($"http://order-service/api/orders/{userId}");
var analytics = await httpClient.GetFromJsonAsync<FullAnalyticsDto>($"http://analytics-service/api/analytics/user/{userId}");
var recommendations = await httpClient.GetFromJsonAsync<RecommendationDto[]>($"http://recommendation-service/api/recommendations/{userId}");
return Results.Ok(new
{
user,
orders = allOrders,
analytics,
recommendations,
trends = analytics?.Trends
});
});
}
}Rate Limiting on Gateway
// Register rate limiter
builder.Services.AddRateLimiter(options =>
{
options.AddPolicy("api-limit", context =>
{
var apiKey = context.HttpContext.Request.Headers["X-Api-Key"].ToString();
return apiKey switch
{
"premium-key" => RateLimitPartition.GetFixedWindowLimiter(
apiKey, _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 1000,
Window = TimeSpan.FromMinutes(1)
}),
_ => RateLimitPartition.GetFixedWindowLimiter(
apiKey, _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 100,
Window = TimeSpan.FromMinutes(1)
})
};
});
});
// Apply to YARP
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.ConfigureLimiter((context, config) =>
{
config.AddRateLimiter(new RateLimiterOptions
{
RejectionStatusCode = 429,
OnRejected = async (context, token) =>
{
context.HttpContext.Response.Headers.RetryAfter = "60";
await context.HttpContext.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = 429,
Title = "Too Many Requests",
Detail = "Rate limit exceeded. Please retry after the specified time."
}, cancellationToken: token);
}
});
});Circuit Breaking
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.ConfigureHttpClient((context, client) =>
{
// Add Polly circuit breaker
var policyRegistry = context.ServiceProvider.GetRequiredService<IPolicyRegistry>();
var circuitBreakerPolicy = Policy
.Handle<HttpRequestException>()
.Or<TimeoutException>()
.CircuitBreakerAsync(
exceptionsAllowedBeforeBreaking: 5,
durationOfBreak: TimeSpan.FromMinutes(1),
onBreak: (outcome, timespan) =>
{
// Circuit opened
},
onReset: () =>
{
// Circuit closed
});
client.Timeout = TimeSpan.FromSeconds(30);
});Practice Tasks
YARP as API Gateway with Health-Based Load Balancing
Set up YARP with:
- Multiple routes for different services
- Health-based load balancing with active health checks
- Rate limiting per route
Request Aggregation for Reducing Client Round Trips
Implement a composition service that aggregates:
- User profile from User Service
- Recent orders from Order Service
- Notifications from Notification Service
into a single API call.
BFF Layer for Mobile Client with Custom Payload Shaping
Create a BFF layer that:
- Aggregates data from multiple backend services
- Shapes responses specifically for mobile clients
- Minimizes payload size by removing unnecessary fields
- Implements pagination and field selection
References
- [YARP Documentation](https://microsoft.github.io/reverse-proxy/)
- [YARP GitHub Repository](https://github.com/dotnet/yarp)
- [Backend for Frontend Pattern](https://docs.microsoft.com/architecture/patterns/backends-for-frontends/)
- [API Composition Pattern](https://microservices.io/patterns/apigateway.html)
Практика
API Evolution и Versioning
# API Evolution and Versioning
Backward Compatibility Rules
Additive Changes Only
The golden rule of API evolution: only make additive changes to maintain backward compatibility.
Compatible Changes (Safe)
| Change | Safe? | Reason |
|---|---|---|
| Adding a new field to a response | Yes | Clients ignore unknown fields |
| Adding a new optional parameter | Yes | Existing clients don't send it |
| Adding a new endpoint | Yes | No impact on existing clients |
| Adding a new enum value | Yes | Clients that don't know it can ignore |
| Adding a new optional header | Yes | Existing clients don't send it |
| Widening a field type (int -> long) | Yes | Superset of original values |
Breaking Changes (Dangerous)
| Change | Impact | Migration |
|---|---|---|
| Removing a field | Clients may fail parsing | Deprecate first, then remove |
| Renaming a field | Clients break on access | Use alias during transition |
| Changing field type | Parsing errors | Add new field, deprecate old |
| Making optional field required | Validation errors | Make optional first |
| Removing an endpoint | Clients can't call it | Redirect or provide alternative |
| Changing enum values | Logic errors | Add new values, deprecate old |
| Changing response structure | Parsing errors | Provide migration path |
Example: Safe Evolution
// v1 response
public class UserDtoV1
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
}
// v2 response (backward compatible - additive only)
public class UserDtoV2
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string? AvatarUrl { get; set; } // NEW: optional field
public DateTime? LastLoginAt { get; set; } // NEW: optional field
public AddressDto? Address { get; set; } // NEW: optional nested object
}Deprecation Strategy
RFC 8594 Headers
public class DeprecationMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<DeprecationMiddleware> _logger;
public DeprecationMiddleware(RequestDelegate next, ILogger<DeprecationMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var apiVersion = context.GetApiVersion()?.ToString();
// Check if the requested version is deprecated
if (IsDeprecated(apiVersion))
{
var sunsetDate = GetSunsetDate(apiVersion);
// RFC 8594: Deprecation header
context.Response.Headers.Deprecation = "true";
// RFC 8594: Sunset header
context.Response.Headers.Sunset = sunsetDate.ToString("R");
// Link header for successor version
context.Response.Headers.Link = $"<https://api.example.com/v2/>; rel=\"successor-version\"";
context.Response.Headers.Link += $", <https://docs.example.com/migration/v{apiVersion}-to-v2>; rel=\"deprecation\"";
// X-API-Deprecation header (custom, widely adopted)
context.Response.Headers["X-API-Deprecation"] = "true";
// X-API-Sunset-Date header
context.Response.Headers["X-API-Sunset-Date"] = sunsetDate.ToString("yyyy-MM-dd");
_logger.LogInformation(
"Deprecated API accessed: v{Version} at {Path}",
apiVersion, context.Request.Path);
}
await _next(context);
}
private bool IsDeprecated(string? version)
{
return version?.StartsWith("1.") == true;
}
private DateTime GetSunsetDate(string? version)
{
return new DateTime(2026, 12, 31);
}
}Automatic Client Notification
public class DeprecationController : ControllerBase
{
/// <summary>
/// Get deprecation status for all API versions
/// </summary>
[HttpGet("/api/deprecation")]
[ApiExplorerSettings(IgnoreApi = false)]
public IActionResult GetDeprecationStatus()
{
return Ok(new
{
currentVersion = "2.0",
deprecatedVersions = new[]
{
new
{
version = "1.0",
status = "deprecated",
sunsetDate = new DateTime(2026, 12, 31).ToString("yyyy-MM-dd"),
migrationGuide = "https://docs.example.com/migration/v1-to-v2",
successorVersion = "2.0",
daysUntilSunset = (new DateTime(2026, 12, 31) - DateTime.UtcNow).Days
}
},
recommendedVersion = "2.0"
});
}
}Deprecation in OpenAPI
// Mark endpoints as deprecated in OpenAPI
app.MapGet("/api/v1/posts", GetPosts)
.WithOpenApi(op =>
{
op.Deprecated = true;
op.Extensions.Add("x-sunset-date", new OpenApiString("2026-12-31"));
op.Extensions.Add("x-migration-guide", new OpenApiString("https://docs.example.com/migration/v1-to-v2"));
return op;
});Contract Testing with Pact
Consumer-Driven Contracts
Pact is a tool for testing consumer-driven contracts. It allows consumers to define expectations (contracts) and providers to verify they meet those expectations.
Setup
dotnet add package PactNet
dotnet add package Pact.VerifierConsumer Side — Defining Contracts
public class UserApiConsumerTests
{
private PactFlow _pact;
[SetUp]
public void SetUp()
{
_pact = new PactFlow(
consumerName: "my-app",
providerName: "user-service");
}
[Test]
public void TestGetUserReturnsValidResponse()
{
_pact
.Given("User exists with id 123")
.UponReceiving("A request for user 123")
.WithRequest(new PactRequest
{
Method = "GET",
Path = "/api/users/123",
Headers = new Dictionary<string, string>
{
{ "Accept", "application/json" }
}
})
.WillRespondWith(new PactResponse
{
Status = 200,
Headers = new Dictionary<string, string>
{
{ "Content-Type", "application/json" }
},
Body = new
{
id = 123,
name = "John Doe",
email = "john@example.com"
}
})
.Verify();
}
[Test]
public void TestGetUserReturns404WhenNotFound()
{
_pact
.Given("User does not exist with id 999")
.UponReceiving("A request for non-existent user 999")
.WithRequest(new PactRequest
{
Method = "GET",
Path = "/api/users/999"
})
.WillRespondWith(new PactResponse
{
Status = 404,
Body = new
{
error = "User not found",
code = "USER_NOT_FOUND"
}
})
.Verify();
}
}Provider Side — Verifying Contracts
public class UserApiProviderTests
{
[Test]
public async Task VerifyContracts()
{
var verifier = new PactVerifier(
"user-service",
opts => opts
.ServiceRoot("http://localhost:5000")
.InMemoryPact()
.PublishToPactFlow("https://pactflow.io/pacts")
.ConsumerVersionSelector(new ConsumerVersionSelector
{
Branch = "main",
Latest = true
}));
// Verify against all consumer contracts
var result = await verifier
.WithHost(new TestServerBuilder()
.ConfigureServices(services => services.AddMvc())
.Configure(app => app.MapUserEndpoints())
.Build());
var verificationResult = await verifier.Verify();
Assert.That(verificationResult.Success, Is.True);
}
}Pact Broker Integration
// Publish pact to Pact Broker
builder.Services.AddPact(options =>
{
options.BrokerUrl = "https://pact-broker.example.com";
options.ConsumerVersion = "1.0.0";
options.ProviderVersion = "2.0.0";
options.ProviderBranch = "main";
});
// Verify against Pact Broker
var pactVerifier = new PactBrokerVerifier(
brokerUrl: "https://pact-broker.example.com",
consumerVersionSelector: new ConsumerVersionSelector
{
Branch = "main",
Latest = true
});
var result = await pactVerifier.Verify();Semantic Versioning for APIs
API Version Semantics
| Version | Meaning | Breaking Changes |
|---|---|---|
| 1.0.0 | Initial stable release | N/A |
| 1.1.0 | New features added | No |
| 1.1.1 | Bug fixes | No |
| 2.0.0 | Breaking changes | Yes |
API Version vs Product Version
API Version: 1.2.3
├── Major (1): Breaking changes introduced
├── Minor (2): New features, backward compatible
└── Patch (3): Bug fixes, no new featuresVersion in Different Strategies
// URL Path Versioning
app.MapGroup("/api/v1/users").MapUserEndpointsV1();
app.MapGroup("/api/v2/users").MapUserEndpointsV2();
// Header Versioning
app.MapGet("/api/users", GetUsers)
.WithMetadata(new ApiVersionMetadata { AcceptedApiVersions = ["1.0", "2.0"] });
// Query String Versioning
app.MapGet("/api/users", GetUsers)
.Accepts<GetUsersRequest>("1.0")
.Accepts<GetUsersRequestV2>("2.0");Migration Patterns
Parallel Versions
// Run both versions simultaneously
app.MapGroup("/api/v1/users").MapUserEndpointsV1();
app.MapGroup("/api/v2/users").MapUserEndpointsV2();
// Feature flag for gradual rollout
app.MapGet("/api/users", async (HttpContext ctx, IUserService service) =>
{
var useV2 = ctx.Request.Headers["X-Use-V2"] == "true" ||
ctx.User.HasClaim("feature:v2-api", "enabled");
if (useV2)
return Results.Ok(await service.GetUsersV2Async());
return Results.Ok(await service.GetUsersV1Async());
});Adapter Pattern for Migration
// v1 adapter that maps v2 data to v1 shape
public class UserV1Adapter
{
public UserDtoV1 Adapt(UserDtoV2 v2)
{
return new UserDtoV1
{
Id = v2.Id,
Name = v2.FullName, // Mapped from renamed field
Email = v2.EmailAddress, // Mapped from renamed field
// AvatarUrl and LastLoginAt intentionally omitted for v1
};
}
}
// Controller that uses adapter
app.MapGet("/api/v1/users/{id}", async (
int id,
IUserService userService,
UserV1Adapter adapter) =>
{
var userV2 = await userService.GetUserV2Async(id);
var userV1 = adapter.Adapt(userV2);
return Results.Ok(userV1);
});Migration Guide Generator
// Generate migration guide from OpenAPI diff
public class MigrationGuideGenerator
{
public async Task<string> GenerateAsync(string oldSpecPath, string newSpecPath)
{
var oldSpec = await LoadOpenApiSpec(oldSpecPath);
var newSpec = await LoadOpenApiSpec(newSpecPath);
var changes = OpenApiDiff.Compare(oldSpec, newSpec);
var guide = new StringBuilder();
guide.AppendLine("# API Migration Guide\n");
foreach (var change in changes.BreakingChanges)
{
guide.AppendLine($"## Breaking: {change.Summary}\n");
guide.AppendLine($"**Location:** {change.Path}\n");
guide.AppendLine($"**Old:** {change.OldValue}\n");
guide.AppendLine($"**New:** {change.NewValue}\n");
guide.AppendLine($"**Migration:** {change.Suggestion}\n");
}
foreach (var change in changes.AdditiveChanges)
{
guide.AppendLine($"## Added: {change.Summary}\n");
guide.AppendLine($"**Location:** {change.Path}\n");
guide.AppendLine($"**Details:** {change.Description}\n");
}
return guide.ToString();
}
}Practice Tasks
Deprecation Workflow with Automatic Client Notification
Implement:
- Deprecation middleware with RFC 8594 headers (Deprecation, Sunset, Link)
/api/deprecationendpoint showing all deprecated versions- OpenAPI annotations marking deprecated endpoints
- X-API-Deprecation and X-API-Sunset-Date custom headers
Contract Test Suite for Multi-Version API Compatibility
Implement:
- Pact consumer tests for v1 and v2 APIs
- Pact provider verification tests
- Pact Broker integration for CI/CD
- Automated contract publishing
Migration Guide Generator from OpenAPI Diff
Implement:
- OpenAPI spec comparison tool
- Breaking change detection
- Additive change documentation
- Automated migration guide generation
References
- [RFC 8594 - Deprecation and Sunset Headers](https://www.rfc-editor.org/rfc/rfc8594)
- [RFC 9457 - Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc9457)
- [Pact Documentation](https://docs.pact.io/)
- [API Versioning with ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/web-api/api-versioning)
- [Semantic Versioning 2.0.0](https://semver.org/)
Практика
Event-Driven APIs
# Event-Driven APIs
Webhooks
Overview
Webhooks allow external systems to receive real-time notifications when events occur. The server makes HTTP callbacks to registered URLs.
Webhook Registration
public class WebhookRegistration
{
public Guid Id { get; set; }
public string Url { get; set; } = string.Empty;
public string Secret { get; set; } = string.Empty;
public string[] Events { get; set; } = Array.Empty<string>();
public bool IsActive { get; set; } = true;
public int RetryCount { get; set; }
public int MaxRetries { get; set; } = 5;
public DateTime? LastDeliveryAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
// Register webhook endpoint
app.MapPost("/api/webhooks", async (
RegisterWebhookRequest request,
[FromServices] IWebhookService webhookService) =>
{
var webhook = await webhookService.RegisterAsync(
request.Url,
request.Events,
request.Secret);
return Results.Created($"/api/webhooks/{webhook.Id}", webhook);
});
public record RegisterWebhookRequest(
[Required, Url] string Url,
[Required] string[] Events,
string? Secret = null);Webhook Delivery with Retry
public class WebhookDeliveryService
{
private readonly HttpClient _httpClient;
private readonly ILogger<WebhookDeliveryService> _logger;
private readonly ApplicationDb _db;
public WebhookDeliveryService(
IHttpClientFactory factory,
ILogger<WebhookDeliveryService> logger,
ApplicationDb db)
{
_httpClient = factory.CreateClient("webhook");
_logger = logger;
_db = db;
}
public async Task DeliverAsync(WebhookRegistration webhook, WebhookEvent @event)
{
var delivery = new WebhookDelivery
{
WebhookId = webhook.Id,
Event = @event.Type,
Payload = JsonSerializer.Serialize(@event.Data),
Timestamp = DateTime.UtcNow,
Status = DeliveryStatus.Pending,
AttemptCount = 0
};
_db.Deliveries.Add(delivery);
await _db.SaveChangesAsync();
await RetryDeliverAsync(webhook, @event, delivery, 0);
}
private async Task RetryDeliverAsync(
WebhookRegistration webhook,
WebhookEvent @event,
WebhookDelivery delivery,
int attempt)
{
try
{
var signature = GenerateSignature(webhook.Secret, delivery.Id.ToString(), @event);
var content = new StringContent(delivery.Payload, Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync(webhook.Url, content);
if (response.IsSuccessStatusCode)
{
delivery.Status = DeliveryStatus.Success;
delivery.ResponseStatusCode = (int)response.StatusCode;
delivery.DeliveredAt = DateTime.UtcNow;
delivery.LastError = null;
}
else
{
throw new HttpRequestException(
$"Webhook delivery failed with status {(int)response.StatusCode}");
}
}
catch (Exception ex)
{
delivery.AttemptCount = attempt + 1;
delivery.LastError = ex.Message;
delivery.Status = attempt >= webhook.MaxRetries
? DeliveryStatus.Failed
: DeliveryStatus.Retrying;
if (attempt < webhook.MaxRetries)
{
// Exponential backoff: 1s, 2s, 4s, 8s, 16s
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt));
await Task.Delay(delay);
await RetryDeliverAsync(webhook, @event, delivery, attempt + 1);
}
}
finally
{
_db.Deliveries.Update(delivery);
await _db.SaveChangesAsync();
}
}
private string GenerateSignature(string secret, string deliveryId, WebhookEvent @event)
{
var payload = $"{deliveryId}.{@event.Timestamp:O}.{JsonSerializer.Serialize(@event.Data)}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
return Convert.ToBase64String(hash);
}
}
public class WebhookEvent
{
public string Type { get; set; } = string.Empty;
public string Id { get; set; } = Guid.NewGuid().ToString();
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
public object Data { get; set; } = new();
}
public enum DeliveryStatus
{
Pending,
Retrying,
Success,
Failed
}Signature Verification on Receiver Side
public class WebhookVerificationMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<WebhookVerificationMiddleware> _logger;
public WebhookVerificationMiddleware(RequestDelegate next, ILogger<WebhookVerificationMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
if (context.Request.Path == "/webhooks/receiver" &&
context.Request.Method == "POST")
{
var signature = context.Request.Headers["X-Webhook-Signature"].ToString();
var timestamp = context.Request.Headers["X-Webhook-Timestamp"].ToString();
if (string.IsNullOrEmpty(signature) || string.IsNullOrEmpty(timestamp))
{
context.Response.StatusCode = 401;
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = 401,
Title = "Missing authentication headers",
Detail = "X-Webhook-Signature and X-Webhook-Timestamp headers are required"
});
return;
}
// Verify timestamp is not too old (tolerance: 5 minutes)
if (!VerifyTimestamp(timestamp))
{
context.Response.StatusCode = 401;
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = 401,
Title = "Timestamp expired",
Detail = "Webhook timestamp is older than 5 minutes"
});
return;
}
// Verify signature
var body = await new StreamReader(context.Request.Body).ReadToEndAsync();
context.Request.Body.Position = 0;
var expectedSignature = ComputeSignature(body, timestamp, _webhookSecret);
if (!CompareHashes(signature, expectedSignature))
{
_logger.LogWarning("Invalid webhook signature received");
context.Response.StatusCode = 401;
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = 401,
Title = "Invalid signature",
Detail = "Webhook signature verification failed"
});
return;
}
await _next(context);
}
else
{
await _next(context);
}
}
private bool VerifyTimestamp(string timestamp)
{
if (!DateTime.TryParse(timestamp, out var ts)) return false;
return (DateTime.UtcNow - ts).TotalMinutes <= 5;
}
private string ComputeSignature(string body, string timestamp, string secret)
{
var payload = $"{timestamp}.{body}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
return Convert.ToBase64String(hash);
}
private bool CompareHashes(string a, string b)
{
return CryptographicOperations.FixedTimeEqual(
Encoding.UTF8.GetBytes(a),
Encoding.UTF8.GetBytes(b));
}
}Server-Sent Events (SSE)
Overview
SSE provides a simple way to push real-time text data from server to client over HTTP. It is simpler than WebSocket and only supports server-to-client communication.
SSE Endpoint
app.MapGet("/api/events/stream", async (HttpContext context, CancellationToken ct) =>
{
context.Response.Headers.ContentType = "text/event-stream";
context.Response.Headers.CacheControl = "no-cache";
context.Response.Headers.Connection = "Keep-Alive";
var eventSource = context.RequestServices.GetRequiredService<ISseEventSource>();
await foreach (var eventData in eventSource.SubscribeAsync(ct))
{
if (context.RequestAborted.IsCancellationRequested) break;
await context.Response.WriteAsync(
$"id: {eventData.Id}\n", ct);
await context.Response.WriteAsync(
$"event: {eventData.Type}\n", ct);
await context.Response.WriteAsync(
$"data: {JsonSerializer.Serialize(eventData.Payload)}\n\n", ct);
}
});
public interface ISseEventSource
{
IAsyncEnumerable<SseEventData> SubscribeAsync(CancellationToken ct);
}
public class SseEventSource : ISseEventSource
{
private readonly Channel<SseEventData> _channel;
private readonly ILogger<SseEventSource> _logger;
public SseEventSource(ILogger<SseEventSource> logger)
{
_channel = Channel.CreateBounded<SseEventData>(new BoundedChannelOptions(1000)
{
FullMode = BoundedChannelFullMode.Wait
});
_logger = logger;
}
public async IAsyncEnumerable<SseEventData> SubscribeAsync([EnumeratorCancellation] CancellationToken ct)
{
await foreach (var eventItem in _channel.Reader.ReadAllAsync(ct))
{
yield return eventItem;
}
}
public Task PublishAsync(SseEventData eventData)
{
_logger.LogInformation("Publishing SSE event: {Type}", eventData.Type);
return _channel.Writer.WriteAsync(eventData, ct: CancellationToken.None);
}
}
public record SseEventData(
string Id,
string Type,
object Payload,
DateTime Timestamp = default);Heartbeat for SSE
app.MapGet("/api/events/stream-with-heartbeat", async (HttpContext context, CancellationToken ct) =>
{
context.Response.Headers.ContentType = "text/event-stream";
context.Response.Headers.CacheControl = "no-cache";
var eventSource = context.RequestServices.GetRequiredService<ISseEventSource>();
var heartbeatTimer = new PeriodicTimer(TimeSpan.FromSeconds(15));
try
{
while (!ct.IsCancellationRequested)
{
var hasEvent = await Task.WhenAny(
ConsumeEventsAsync(eventSource, context, ct),
WaitHeartbeatAsync(heartbeatTimer, context)
).ConfigureAwait(false);
if (!hasEvent) break;
}
}
catch (TaskCanceledException) { /* Expected on disconnect */ }
});
private static async Task<bool> WaitHeartbeatAsync(
PeriodicTimer timer, HttpContext context)
{
var completed = await timer.WaitForNextTickAsync(context.RequestAborted);
if (!completed) return false;
await context.Response.WriteAsync(": heartbeat\n\n", CancellationToken.None);
return true;
}
private static async Task<bool> ConsumeEventsAsync(
ISseEventSource eventSource, HttpContext context, CancellationToken ct)
{
await foreach (var eventData in eventSource.SubscribeAsync(ct))
{
if (context.RequestAborted.IsCancellationRequested) return false;
await context.Response.WriteAsync(
$"id: {eventData.Id}\nevent: {eventData.Type}\ndata: {JsonSerializer.Serialize(eventData.Payload)}\n\n",
ct);
await context.Response.Body.FlushAsync(ct);
}
return false;
}SSE Client (JavaScript)
// Connect to SSE stream
const eventSource = new EventSource('/api/events/stream');
eventSource.addEventListener('post.created', (event) => {
const post = JSON.parse(event.data);
console.log('New post:', post.title);
addPostToUI(post);
});
eventSource.addEventListener('order.updated', (event) => {
const order = JSON.parse(event.data);
console.log('Order updated:', order.status);
updateOrderUI(order);
});
eventSource.onerror = (error) => {
console.error('SSE connection error:', error);
eventSource.close();
// Reconnect after delay
setTimeout(() => connectSSE(), 3000);
};WebSocket API
WebSocket Handler
public class ChatWebSocketHandler : WebSocketHandler
{
private static readonly ConcurrentDictionary<string, WebSocket> _clients = new();
private static readonly ConcurrentDictionary<string, List<string>> _rooms = new();
private readonly ILogger<ChatWebSocketHandler> _logger;
private readonly IAuthTokenService _authService;
public ChatWebSocketHandler(ILogger<ChatWebSocketHandler> logger, IAuthTokenService authService)
{
_logger = logger;
_authService = authService;
}
public override async Task OnConnectedAsync(WebSocket webSocket)
{
var userId = ExtractUserId(webSocket);
_clients[userId] = webSocket;
_logger.LogInformation("User {UserId} connected. Total clients: {Count}",
userId, _clients.Count);
await SendToClient(userId, new WebSocketMessage
{
Type = MessageType.Connected,
Payload = new { userId, timestamp = DateTime.UtcNow }
});
await HandleMessages(webSocket, userId);
}
public override async Task OnDisconnectedAsync(WebSocket webSocket)
{
var userId = _clients.FirstOrDefault(c => c.Value == webSocket).Key;
if (userId != null)
{
_clients.TryRemove(userId, out _);
// Notify room members
foreach (var roomId in _rooms.Keys.Where(r => _rooms[r].Contains(userId)))
{
await BroadcastToRoom(roomId, new WebSocketMessage
{
Type = MessageType.UserLeft,
Payload = new { userId }
}, excludeUserId: userId);
}
_logger.LogInformation("User {UserId} disconnected. Total clients: {Count}",
userId, _clients.Count);
}
}
private async Task HandleMessages(WebSocket webSocket, string userId)
{
var buffer = new byte[1024 * 4];
try
{
while (webSocket.State == WebSocketState.Open)
{
var result = await webSocket.ReceiveAsync(
new ArraySegment<byte>(buffer), CancellationToken.None);
if (result.MessageType == WebSocketMessageType.Close)
{
await webSocket.CloseAsync(
WebSocketCloseStatus.NormalClosure, "Closing", CancellationToken.None);
break;
}
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
await ProcessMessage(userId, message);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling messages for user {UserId}", userId);
}
}
private async Task ProcessMessage(string userId, string messageJson)
{
var message = JsonSerializer.Deserialize<WebSocketMessage>(messageJson);
if (message == null) return;
switch (message.Type)
{
case MessageType.JoinRoom:
await JoinRoom(userId, message.Payload);
break;
case MessageType.SendMessage:
await SendMessage(userId, message.Payload);
break;
case MessageType.Heartbeat:
await SendToClient(userId, new WebSocketMessage
{
Type = MessageType.Pong,
Payload = new { timestamp = DateTime.UtcNow }
});
break;
}
}
private async Task SendMessage(string fromUserId, object payload)
{
var messageData = (dynamic)payload;
var roomId = messageData.roomId;
var content = messageData.content;
var chatMessage = new WebSocketMessage
{
Type = MessageType.ChatMessage,
Payload = new
{
fromUserId,
roomId,
content,
timestamp = DateTime.UtcNow
}
};
await BroadcastToRoom(roomId, chatMessage, excludeUserId: fromUserId);
}
private async Task BroadcastToRoom(string roomId, WebSocketMessage message, string? excludeUserId = null)
{
if (!_rooms.TryGetValue(roomId, out var users)) return;
foreach (var userId in users.Where(u => u != excludeUserId))
{
if (_clients.TryGetValue(userId, out var socket) &&
socket.State == WebSocketState.Open)
{
var json = JsonSerializer.Serialize(message);
await socket.SendAsync(
new ArraySegment<byte>(Encoding.UTF8.GetBytes(json)),
WebSocketMessageType.Text,
true,
CancellationToken.None);
}
}
}
// Heartbeat and reconnection
private async Task SendHeartbeatAsync(string userId)
{
if (_clients.TryGetValue(userId, out var socket) &&
socket.State == WebSocketState.Open)
{
var message = new WebSocketMessage
{
Type = MessageType.Heartbeat,
Payload = new { timestamp = DateTime.UtcNow }
};
var json = JsonSerializer.Serialize(message);
await socket.SendAsync(
new ArraySegment<byte>(Encoding.UTF8.GetBytes(json)),
WebSocketMessageType.Text,
true,
CancellationToken.None);
}
}
}
public enum MessageType
{
Connected,
UserJoined,
UserLeft,
ChatMessage,
Heartbeat,
Pong,
JoinRoom,
SendMessage
}
public record WebSocketMessage
{
public MessageType Type { get; set; }
public object? Payload { get; set; }
public string? Id { get; set; } = Guid.NewGuid().ToString();
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
}WebSocket with Reconnection Logic
// Server-side heartbeat
app.UseWebSockets(new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(30),
ReceiveBufferSize = 4 * 1024
});
app.MapWebSocketManager("/ws/chat", new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(30)
}, manager => new ChatWebSocketHandler(logger, authService));
// Client-side reconnection (JavaScript)
function connectWebSocket() {
const ws = new WebSocket('wss://api.example.com/ws/chat');
ws.onopen = () => {
console.log('Connected');
sendHeartbeat(ws);
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
handleMessage(message);
if (message.type === 'heartbeat') {
// Send pong
ws.send(JSON.stringify({ type: 'pong' }));
}
};
ws.onclose = (event) => {
console.log(`Disconnected: code=${event.code}, reason=${event.reason}`);
// Exponential backoff reconnection
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
reconnectAttempts++;
setTimeout(() => connectWebSocket(), delay);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
return ws;
}
function sendHeartbeat(ws) {
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'heartbeat' }));
}
}, 25000); // Send every 25s (server expects every 30s)
}Async API Specification
Async API Overview
AsyncAPI is to event-driven APIs what OpenAPI is to REST APIs. It provides a machine-readable format for describing event-driven interfaces.
AsyncAPI Specification Example
asyncapi: 3.0.0
info:
title: Order Service Events
version: 1.0.0
description: Event-driven API for order management
servers:
production:
url: mqtt://events.example.com
protocol: mqtt
channels:
order.created:
address: orders/events/created
messages:
OrderCreated:
$ref: '#/components/messages/OrderCreated'
order.updated:
address: orders/events/updated
messages:
OrderUpdated:
$ref: '#/components/messages/OrderUpdated'
order.cancelled:
address: orders/events/cancelled
messages:
OrderCancelled:
$ref: '#/components/messages/OrderCancelled'
components:
messages:
OrderCreated:
name: OrderCreated
contentType: application/json
payload:
type: object
properties:
orderId:
type: string
format: uuid
customerId:
type: string
format: uuid
total:
type: number
items:
type: array
items:
type: object
examples:
- name: SampleOrder
payload:
orderId: "550e8400-e29b-41d4-a716-446655440000"
customerId: "650e8400-e29b-41d4-a716-446655440001"
total: 150.00
items:
- productId: "prod-123"
quantity: 2
price: 75.00
OrderUpdated:
name: OrderUpdated
contentType: application/json
payload:
type: object
properties:
orderId:
type: string
format: uuid
status:
type: string
enum: [confirmed, shipped, delivered, cancelled]
updatedAt:
type: string
format: date-time
OrderCancelled:
name: OrderCancelled
contentType: application/json
payload:
type: object
properties:
orderId:
type: string
format: uuid
reason:
type: string
cancelledAt:
type: string
format: date-timePractice Tasks
Webhook System with Reliable Delivery and Signature Verification
Implement:
- Webhook registration endpoint
- Delivery with exponential backoff retry (up to 5 attempts)
- HMAC-SHA256 signature generation and verification
- X-Webhook-Signature and X-Webhook-Timestamp headers
- Delivery status tracking
SSE Endpoint for Real-Time Notification Stream
Implement:
- SSE endpoint with proper headers (text/event-stream)
- Heartbeat mechanism (every 15 seconds)
- Event typing (post.created, order.updated, etc.)
- Client reconnection handling
WebSocket API with Custom Message Protocol and Reconnection Logic
Implement:
- WebSocket handler with authentication
- Custom message protocol (type, payload, id, timestamp)
- Heartbeat/pong mechanism
- Room-based messaging
- Exponential backoff reconnection on client side
References
- [RFC 8594 - Deprecation and Sunset Headers](https://www.rfc-editor.org/rfc/rfc8594)
- [Server-Sent Events Specification](https://html.spec.whatwg.org/multipage/server-sent-events.html)
- [WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket)
- [AsyncAPI Specification](https://www.asyncapi.com/docs/specifications/v3.0.0)
- [Pact Documentation](https://docs.pact.io/)
Практика
API Performance Optimization
# API Performance Optimization
Response Compression
Configuration
// Program.cs
builder.Services.AddResponseCompression(options =>
{
options.EnableForHttps = true;
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[]
{
"application/json",
"application/xml",
"text/plain",
"application/javascript",
"text/css"
});
options.Level = CompressionLevel.Optimal; // NoCompression, Fastest, Optimal
});
var app = builder.Build();
app.UseResponseCompression();appsettings.json Configuration
{
"ResponseCompression": {
"EnableForHttps": true,
"Level": "Optimal",
"MimeTypes": [
"application/json",
"application/xml",
"text/plain",
"application/javascript",
"text/css"
]
}
}Content Negotiation
// Client requests specific encoding
GET /api/posts HTTP/1.1
Accept-Encoding: gzip, br, deflate
// Server responds with best available encoding
HTTP/1.1 200 OK
Content-Encoding: gzip
Vary: Accept-Encoding
Content-Type: application/json
Content-Length: 1234Brotli vs Gzip Comparison
| Algorithm | Compression Ratio | Compression Speed | Decompression Speed |
|---|---|---|---|
| Brotli | ~20% better than gzip | Slower | Similar |
| Gzip | Baseline | Faster | Faster |
| Deflate | ~10% worse than gzip | Fastest | Fastest |
Custom Compression Middleware
public class CompressionMiddleware
{
private readonly RequestDelegate _next;
private readonly ResponseCompressionService _compression;
public CompressionMiddleware(RequestDelegate next, IResponseCompressionService compression)
{
_next = next;
_compression = compression;
}
public async Task InvokeAsync(HttpContext context)
{
var acceptEncoding = context.Request.Headers["Accept-Encoding"].ToString();
if (acceptEncoding.Contains("br"))
{
context.Response.Headers.Vary = "Accept-Encoding";
var compressed = await _compression.CompressAsync(context.Response.Body, CompressionFormat.Brotli);
context.Response.Headers.ContentEncoding = "br";
await _next(context);
return;
}
if (acceptEncoding.Contains("gzip"))
{
context.Response.Headers.Vary = "Accept-Encoding";
var compressed = await _compression.CompressAsync(context.Response.Body, CompressionFormat.Gzip);
context.Response.Headers.ContentEncoding = "gzip";
await _next(context);
return;
}
await _next(context);
}
}Caching Headers
Cache-Control
// Cacheable response
app.MapGet("/api/products/{id}", async (int id, ApplicationDb db) =>
{
var product = await db.Products.FindAsync(id);
if (product == null) return Results.NotFound();
return Results.Ok(product)
.WithHeaders(
ResponseHeadersExtensions.SetCacheControl("public, max-age=3600, stale-while-revalidate=86400"),
ResponseHeadersExtensions.SetETag(product.ETag),
ResponseHeadersExtensions.SetLastModified(product.UpdatedAt));
});
// Non-cacheable response
app.MapPost("/api/orders", async (CreateOrderRequest request, ApplicationDb db) =>
{
// ... create order
return Results.Created($"/api/orders/{order.Id}", order)
.WithHeaders(
ResponseHeadersExtensions.SetCacheControl("no-store, no-cache, must-revalidate"),
ResponseHeadersExtensions.SetPragma("no-cache"));
});ETag Implementation
public class ETagMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ETagMiddleware> _logger;
public ETagMiddleware(RequestDelegate next, ILogger<ETagMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
if (context.Request.Method == "GET")
{
var cacheKey = GenerateCacheKey(context.Request);
var etag = await GetEtagAsync(cacheKey);
// Check If-None-Match header
var ifNoneMatch = context.Request.Headers["If-None-Match"].ToString();
if (!string.IsNullOrEmpty(ifNoneMatch) && etag != null)
{
if (ifNoneMatch == $"\"{etag}\"" || ifNoneMatch == "*")
{
context.Response.StatusCode = 304; // Not Modified
context.Response.Headers.ETag = $"\"{etag}\"";
return;
}
}
// Capture response body for ETag generation
var originalBody = context.Response.Body;
using var responseBody = new MemoryStream();
context.Response.Body = responseBody;
await _next(context);
if (context.Response.StatusCode == 200)
{
responseBody.Seek(0, SeekOrigin.Begin);
var bodyBytes = await ReadAllBytesAsync(responseBody);
responseBody.Seek(0, SeekOrigin.Begin);
var newEtag = ComputeEtag(bodyBytes);
context.Response.Headers.ETag = $"\"{newEtag}\"";
await responseBody.CopyToAsync(originalBody);
}
else
{
await responseBody.CopyToAsync(originalBody);
}
}
else
{
await _next(context);
}
}
private string GenerateCacheKey(HttpRequest request)
{
var builder = new StringBuilder();
builder.Append(request.Method);
builder.Append(request.Path);
foreach (var (key, value) in request.Query.OrderBy(k => k.Key))
{
builder.Append(key);
builder.Append(value);
}
return builder.ToString();
}
private async Task<string?> GetEtagAsync(string cacheKey)
{
// Check distributed cache
var cachedEtag = await _distributedCache.GetStringAsync($"etag:{cacheKey}");
return cachedEtag;
}
private string ComputeEtag(byte[] body)
{
using var sha256 = SHA256.Create();
var hash = sha256.ComputeHash(body);
return Convert.ToBase64String(hash);
}
private async Task<byte[]> ReadAllBytesAsync(MemoryStream stream)
{
var bytes = new byte[stream.Length];
await stream.ReadAsync(bytes, 0, bytes.Length);
return bytes;
}
}Last-Modified / If-Modified-Since
app.MapGet("/api/posts", async (int limit, string? cursor, ApplicationDb db) =>
{
var ifModifiedSince = context.Request.Headers["If-Modified-Since"].ToString();
if (!string.IsNullOrEmpty(ifModifiedSince) && DateTime.TryParse(ifModifiedSince, out var modifiedSince))
{
var latestPost = await db.Posts.OrderByDescending(p => p.UpdatedAt).FirstOrDefaultAsync();
if (latestPost != null && latestPost.UpdatedAt <= modifiedSince)
{
return Results.StatusCode(304); // Not Modified
}
}
// ... fetch posts
return results.WithLastModified(latestUpdatedAt);
});Distributed Caching
// Register distributed cache (Redis)
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "ApiCache:";
});
// Cache middleware
public class CacheMiddleware
{
private readonly RequestDelegate _next;
private readonly IDistributedCache _cache;
private readonly ILogger<CacheMiddleware> _logger;
public CacheMiddleware(RequestDelegate next, IDistributedCache cache, ILogger<CacheMiddleware> logger)
{
_next = next;
_cache = cache;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
if (context.Request.Method == "GET" && ShouldCache(context.Request))
{
var cacheKey = GenerateCacheKey(context.Request);
var cached = await _cache.GetStringAsync(cacheKey);
if (cached != null)
{
_logger.LogDebug("Cache hit: {Key}", cacheKey);
await context.Response.WriteAsync(cached);
return;
}
// Cache miss - capture response
var originalBody = context.Response.Body;
using var responseBody = new MemoryStream();
context.Response.Body = responseBody;
await _next(context);
if (context.Response.StatusCode == 200)
{
responseBody.Seek(0, SeekOrigin.Begin);
var response = await new StreamReader(responseBody).ReadToEndAsync();
await _cache.SetStringAsync(cacheKey, response, new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5),
SlidingExpiration = TimeSpan.FromMinutes(2),
Priority = CacheItemPriority.Default
});
_logger.LogDebug("Cached: {Key}", cacheKey);
await responseBody.CopyToAsync(originalBody);
}
else
{
await responseBody.CopyToAsync(originalBody);
}
}
else
{
await _next(context);
}
}
private bool ShouldCache(HttpRequest request)
{
// Don't cache if authenticated or has specific headers
return !request.Headers.ContainsKey("Authorization") &&
!request.Headers.ContainsKey("Cookie");
}
}Partial Responses
HTTP Range Requests
app.MapGet("/api/files/{id}", async (int id, ApplicationDb db, HttpContext context) =>
{
var file = await db.Files.FindAsync(id);
if (file == null) return Results.NotFound();
var fileSize = file.Size;
var rangeHeader = context.Request.Headers["Range"].ToString();
if (!string.IsNullOrEmpty(rangeHeader))
{
// Parse range header: bytes=0-499, bytes=500-999, bytes=-500
var range = ParseRange(rangeHeader, fileSize);
context.Response.StatusCode = 206; // Partial Content
context.Response.Headers.AcceptRanges = "bytes";
context.Response.Headers.ContentRange = $"bytes {range.Start}-{range.End}/{fileSize}";
context.Response.Headers.ContentLength = range.End - range.Start + 1;
await SendPartialContent(file, range.Start, range.End);
}
else
{
context.Response.Headers.AcceptRanges = "bytes";
context.Response.Headers.ContentLength = fileSize;
await SendFullContent(file);
}
});
private (long Start, long End) ParseRange(string rangeHeader, long fileSize)
{
var range = rangeHeader.Substring("bytes=".Length).Split('-');
var start = range.Length > 0 && !string.IsNullOrEmpty(range[0])
? long.Parse(range[0]) : 0;
var end = range.Length > 1 && !string.IsNullOrEmpty(range[1])
? long.Parse(range[1]) : fileSize - 1;
return (Math.Max(0, start), Math.Min(fileSize - 1, end));
}Field-Level Filtering
// GraphQL-style field filtering for REST
app.MapGet("/api/users", async (HttpContext context, ApplicationDb db) =>
{
var fields = context.Request.Query["fields"].ToString();
var userId = context.GetUserId();
var user = await db.Users.FindAsync(userId);
if (user == null) return Results.NotFound();
if (string.IsNullOrEmpty(fields))
{
// Return full user
return Results.Ok(user);
}
// Parse field selection (supports nested: id,name,email,profile(bio,avatar))
var selectedFields = ParseFieldSelection(fields);
var filtered = FilterUserFields(user, selectedFields);
return Results.Ok(filtered);
});
private Dictionary<string, object> FilterUserFields(User user, HashSet<string> fields)
{
var result = new Dictionary<string, object>();
foreach (var field in fields)
{
if (field == "id") result["id"] = user.Id;
else if (field == "name") result["name"] = user.Name;
else if (field == "email") result["email"] = user.Email;
else if (field == "profile")
{
result["profile"] = new Dictionary<string, object>
{
["bio"] = user.Profile?.Bio,
["avatar"] = user.Profile?.AvatarUrl
};
}
}
return result;
}Connection Optimization
Keep-Alive Configuration
// Configure Kestrel for optimal connections
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2);
options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30);
options.Limits.MaxConcurrentConnections = 100;
options.Limits.MaxConcurrentUpgradedConnections = 100;
options.Limits.MaxRequestBodySize = 10 * 1024 * 1024; // 10MB
});HTTP/2 Configuration
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenAnyIP(5001, listenOptions =>
{
listenOptions.Protocols = HttpProtocols.Http2;
listenOptions.UseHttps();
});
// HTTP/2 settings
options.Protocols = HttpProtocols.Http2;
options.Http2.MaxStreamsPerConnection = 100;
options.Http2.KeepAliveTimeout = TimeSpan.FromMinutes(2);
});HTTP/3 Configuration
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenAnyIP(5002, listenOptions =>
{
listenOptions.Protocols = HttpProtocols.Http3;
listenOptions.UseHttps();
});
});GraphQL Query Cost Analysis & Rate Limiting
Query Cost Calculator
public class QueryCostCalculator
{
private readonly Dictionary<string, int> _fieldCosts;
private readonly int _defaultFieldCost;
public QueryCostCalculator()
{
_defaultFieldCost = 1;
_fieldCosts = new Dictionary<string, int>
{
["Query.posts"] = 5,
["Query.searchProducts"] = 10,
["Post.comments"] = 3,
["Comment.author"] = 2,
["User.posts"] = 5
};
}
public QueryCostResult Calculate(string query, Dictionary<string, object>? variables = null)
{
var document = Parser.Parse(query);
var cost = CalculateCost(document.Operations.First(), variables);
return new QueryCostResult
{
TotalCost = cost,
Depth = GetDepth(document),
FieldCount = GetFieldCount(document)
};
}
private int CalculateCost(SelectionSet selectionSet, Dictionary<string, object>? variables)
{
var totalCost = 0;
foreach (var selection in selectionSet.Selections)
{
if (selection is Field field)
{
var fieldCost = _fieldCosts.GetValueOrDefault(
$"{field.Name}", _defaultFieldCost);
// Multiply by list multiplier if applicable
var listMultiplier = GetListMultiplier(field);
fieldCost *= listMultiplier;
// Recursively calculate nested fields
if (field.SelectionSet != null)
{
fieldCost += CalculateCost(field.SelectionSet, variables);
}
totalCost += fieldCost;
}
}
return totalCost;
}
private int GetListMultiplier(Field field)
{
// Check if field returns a list type
return 1; // Simplified
}
private int GetDepth( Document document)
{
var maxDepth = 0;
foreach (var op in document.Operations)
{
var depth = CalculateDepth(op.SelectionSet);
maxDepth = Math.Max(maxDepth, depth);
}
return maxDepth;
}
private int CalculateDepth(SelectionSet selectionSet)
{
var maxDepth = 1;
foreach (var selection in selectionSet.Selections)
{
if (selection is Field field && field.SelectionSet != null)
{
maxDepth = Math.Max(maxDepth, 1 + CalculateDepth(field.SelectionSet));
}
}
return maxDepth;
}
private int GetFieldCount(Document document)
{
var count = 0;
foreach (var op in document.Operations)
{
count += CountFields(op.SelectionSet);
}
return count;
}
private int CountFields(SelectionSet selectionSet)
{
var count = 0;
foreach (var selection in selectionSet.Selections)
{
if (selection is Field field)
{
count++;
if (field.SelectionSet != null)
count += CountFields(field.SelectionSet);
}
}
return count;
}
}
public record QueryCostResult(int TotalCost, int Depth, int FieldCount);Per-User Rate Limiting
public class GraphQLRateLimiterMiddleware
{
private readonly RequestDelegate _next;
private readonly IDistributedCache _cache;
private readonly ILogger<GraphQLRateLimiterMiddleware> _logger;
private readonly QueryCostCalculator _costCalculator;
public GraphQLRateLimiterMiddleware(
RequestDelegate next,
IDistributedCache cache,
ILogger<GraphQLRateLimiterMiddleware> logger,
QueryCostCalculator costCalculator)
{
_next = next;
_cache = cache;
_logger = logger;
_costCalculator = costCalculator;
}
public async Task InvokeAsync(HttpContext context)
{
if (context.Request.Path.StartsWithSegments("/graphql") &&
context.Request.HasJsonContentType())
{
var body = await new StreamReader(context.Request.Body).ReadToEndAsync();
context.Request.Body.Position = 0;
var request = JsonSerializer.Deserialize<GraphQlRequest>(body);
if (request?.Query != null)
{
var userId = context.GetUserId() ?? "anonymous";
var costResult = _costCalculator.Calculate(request.Query);
// Per-user rate limit
var rateLimitKey = $"graphql:ratelimit:{userId}";
var rateLimit = await _cache.GetStringAsync(rateLimitKey);
var allowed = CheckRateLimit(rateLimit, costResult.TotalCost);
if (!allowed)
{
context.Response.StatusCode = 429;
await context.Response.WriteAsJsonAsync(new
{
errors = new[]
{
new
{
message = "Rate limit exceeded",
extensions = new
{
code = "RATE_LIMITED",
retryAfter = 60
}
}
}
});
return;
}
// Cost-based limits
if (costResult.TotalCost > 1000)
{
context.Response.StatusCode = 422;
await context.Response.WriteAsJsonAsync(new
{
errors = new[]
{
new
{
message = "Query cost too high",
extensions = new
{
code = "QUERY_TOO_EXPENSIVE",
cost = costResult.TotalCost,
maxCost = 1000
}
}
}
});
return;
}
if (costResult.Depth > 10)
{
context.Response.StatusCode = 422;
await context.Response.WriteAsJsonAsync(new
{
errors = new[]
{
new
{
message = "Query depth too deep",
extensions = new
{
code = "QUERY_TOO_DEEP",
depth = costResult.Depth,
maxDepth = 10
}
}
}
});
return;
}
// Record cost
await IncrementRateLimitCounter(rateLimitKey, costResult.TotalCost);
}
}
await _next(context);
}
private bool CheckRateLimit(string? currentRateLimit, int cost)
{
if (string.IsNullOrEmpty(currentRateLimit)) return true;
var parts = currentRateLimit.Split(':');
var currentCost = int.Parse(parts[0]);
var windowEnd = DateTime.Parse(parts[1]);
if (DateTime.UtcNow > windowEnd) return true; // Window expired
return (currentCost + cost) <= 5000; // Max 5000 cost per minute
}
private async Task IncrementRateLimitCounter(string key, int cost)
{
var existing = await _cache.GetStringAsync(key);
var parts = existing?.Split(':') ?? new[] { "0", DateTime.UtcNow.AddMinutes(1).ToString("O") };
var currentCost = int.Parse(parts[0]);
await _cache.SetStringAsync(key, $"{currentCost + cost}:{parts[1]}");
}
}
public record GraphQlRequest(string? Query, string? OperationName, Dictionary<string, object>? Variables);Practice Tasks
Response Compression with Content Negotiation
Implement:
- Response compression middleware supporting gzip, brotli, and deflate
- Content negotiation based on Accept-Encoding header
- Vary: Accept-Encoding header for proper caching
ETag-Based Caching for Expensive Endpoints
Implement:
- ETag generation for GET endpoints
- If-None-Match / 304 Not Modified support
- If-Modified-Since / 304 Not Modified support
- Distributed caching with sliding expiration
GraphQL Query Cost Calculator with Per-User Rate Limits
Implement:
- Query cost analysis based on field complexity
- Depth limiting (max 10 levels)
- Per-user rate limiting (5000 cost per minute)
- Meaningful error responses for exceeded limits
References
- [RFC 7232 - HTTP Caching](https://www.rfc-editor.org/rfc/rfc7232)
- [RFC 9110 - HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110)
- [Response Compression in ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/performance/response-compression)
- [HTTP/2 Specification](https://www.rfc-editor.org/rfc/rfc7540)
- [HTTP/3 Specification](https://www.rfc-editor.org/rfc/rfc9114)
Практика
Контрольная точка модуля 8
# Модульный Проект: Multi-Protocol API Platform
Overview
Build a comprehensive multi-protocol API platform that integrates REST, gRPC, GraphQL, and event-driven APIs behind a unified API Gateway.
Architecture
+------------------+
| Web / Mobile |
| Clients |
+--------+---------+
|
+--------v---------+
| API Gateway |
| (YARP) |
| - Rate Limiting |
| - Circuit Break |
| - Load Balance |
+--------+---------+
|
+------------------+------------------+
| | |
+-----v-----+ +------v------+ +------v------+
| REST API | | gRPC API | | GraphQL API |
| (Blog) | | (Orders) | | (Search) |
+-----+-----+ +------+------+ +------+------+
| | |
+------------------+------------------+
|
+--------v---------+
| Message Bus |
| (RabbitMQ) |
+--------+---------+
|
+------------------+------------------+
| | |
+-----v-----+ +------v------+ +------v------+
| Database | | Redis | | File Store |
| (Postgres)| | Cache | | |
+-----------+ +-------------+ +-------------+Service Breakdown
Blog Service (REST API)
Endpoints:
GET /api/v1/posts- List posts with cursor paginationGET /api/v1/posts/{id}- Get single postPOST /api/v1/posts- Create postPUT /api/v1/posts/{id}- Update postPATCH /api/v1/posts/{id}- Partial updateDELETE /api/v1/posts/{id}- Delete postGET /api/v1/posts/{id}/comments- List commentsPOST /api/v1/posts/{id}/comments- Add comment
Features:
- Cursor-based pagination
- Problem+JSON error responses (RFC 7807)
- OpenAPI documentation with Swashbuckle
- API versioning (v1, v2)
- Deprecation headers for v1
- ETag-based caching
- Response compression (gzip, brotli)
Tech Stack:
- ASP.NET Core Minimal APIs
- Entity Framework Core
- FluentValidation
- Swashbuckle (OpenAPI)
- Microsoft.AspNetCore.Mvc.Versioning
Order Service (gRPC)
Protobuf Contract:
syntax = "proto3";
package ordermanagement;
service OrderService {
rpc CreateOrder(CreateOrderRequest) returns (Order);
rpc GetOrder(OrderId) returns (Order);
rpc UpdateOrder(UpdateOrderRequest) returns (Order);
rpc CancelOrder(OrderId) returns (CancelOrderResponse);
rpc GetOrderUpdates(OrderId) returns (stream OrderUpdate);
rpc TrackOrders(stream OrderQuery) returns (stream OrderTracking);
}
message Order {
int32 id = 1;
string customer_email = 2;
repeated OrderItem items = 3;
OrderStatus status = 4;
Money total_amount = 5;
google.protobuf.Timestamp created_at = 6;
}
message OrderItem {
string product_id = 1;
string product_name = 2;
int32 quantity = 3;
Money unit_price = 4;
}
message Money {
int64 units = 1;
int32 nanos = 2;
string currency_code = 3;
}
enum OrderStatus {
ORDER_STATUS_UNSPECIFIED = 0;
PENDING = 1;
CONFIRMED = 2;
SHIPPED = 3;
DELIVERED = 4;
CANCELLED = 5;
}Features:
- gRPC interceptors (logging, auth, error handling)
- Server streaming for order updates
- Bidirectional streaming for order tracking
- HTTP/2 multiplexing
Tech Stack:
- ASP.NET Core gRPC
- Google.Protobuf
- Grpc.AspNetCore.Server
Search Service (GraphQL)
Schema:
type Query {
searchProducts(query: String!, filters: ProductFilters): ProductSearchResult!
product(id: ID!): Product
category(slug: String!): Category
products(first: Int, after: String, filters: ProductFilters): ProductConnection!
}
type Mutation {
addToCart(input: AddToCartInput!): CartPayload!
checkout(input: CheckoutInput!): OrderPayload!
}
type Subscription {
priceChanged(productId: ID!): PriceUpdate!
stockUpdated(productId: ID!): StockUpdate!
}Features:
- DataLoader for batching database queries
- Query depth limiting (max 10)
- Query cost analysis
- Per-user rate limiting
- Filtering, sorting, pagination
- Subscriptions for real-time updates
Tech Stack:
- Hot Chocolate
- GreenDonut (DataLoader)
- Entity Framework Core
API Gateway (YARP)
Routes:
{
"ReverseProxy": {
"Routes": {
"blog-route": {
"ClusterId": "blog-cluster",
"Match": { "Path": "/api/blog/{**remainder}" },
"Transforms": [
{ "PathRemovePrefix": "/api/blog" }
],
"RateLimiterPolicy": "blog-limit"
},
"orders-route": {
"ClusterId": "orders-cluster",
"Match": { "Path": "/api/orders/{**remainder}" }
},
"graphql-route": {
"ClusterId": "graphql-cluster",
"Match": { "Path": "/graphql" }
},
"websocket-route": {
"ClusterId": "websocket-cluster",
"Match": { "Path": "/ws/{**remainder}" }
}
},
"Clusters": {
"blog-cluster": {
"LoadBalancingPolicy": "round-robin",
"HealthCheck": {
"Active": {
"Enabled": true,
"Interval": "00:00:10",
"Timeout": "00:00:05",
"Path": "/health"
}
},
"Destinations": {
"blog1": { "Address": "http://blog-service:5001" },
"blog2": { "Address": "http://blog-service:5002" }
}
},
"orders-cluster": {
"Destinations": {
"orders1": { "Address": "http://order-service:5003" }
}
},
"graphql-cluster": {
"Destinations": {
"graphql1": { "Address": "http://graphql-service:5004" }
}
}
}
}
}Features:
- Health-based load balancing
- Rate limiting per route
- Circuit breaking with Polly
- Request/response transformation
- BFF layer for mobile and desktop
Webhook System
Endpoints:
POST /api/webhooks- Register webhookGET /api/webhooks- List registered webhooksDELETE /api/webhooks/{id}- Unregister webhookGET /api/webhooks/{id}/deliveries- View delivery history
Features:
- HMAC-SHA256 signature verification
- Exponential backoff retry (up to 5 attempts)
- Delivery status tracking
- Event type filtering
- Webhook health monitoring
Real-Time Events
SSE Endpoint
GET /api/events/stream- Server-sent events stream- Heartbeat every 15 seconds
- Event types: post.created, order.updated, price.changed
WebSocket Endpoint
WS /ws/chat- Real-time chat- Authentication via query parameter or header
- Room-based messaging
- Heartbeat/pong mechanism
- Exponential backoff reconnection
Implementation Steps
Step 1: Blog Service (REST)
- Create ASP.NET Core Web API project
- Set up EF Core with PostgreSQL
- Implement CRUD endpoints with Minimal APIs
- Add cursor-based pagination
- Add FluentValidation
- Add ProblemDetails for error responses
- Add Swashbuckle/OpenAPI documentation
- Add API versioning (v1, v2)
- Add ETag caching
- Add response compression
Step 2: Order Service (gRPC)
- Create gRPC service project
- Define .proto contracts
- Implement OrderService with all method types
- Add gRPC interceptors (logging, auth, error handling)
- Implement server streaming for order updates
- Implement bidirectional streaming for tracking
- Add health checks
Step 3: Search Service (GraphQL)
- Create ASP.NET Core project with Hot Chocolate
- Define schema (queries, mutations, subscriptions)
- Implement DataLoaders for batching
- Add query depth limiting
- Add query cost analysis
- Add per-user rate limiting
- Implement subscriptions for real-time updates
Step 4: API Gateway (YARP)
- Create YARP proxy project
- Configure routes for all services
- Add health-based load balancing
- Add rate limiting
- Add circuit breaking with Polly
- Implement BFF layer for mobile and desktop
Step 5: Webhook System
- Implement webhook registration
- Implement delivery with retry
- Add signature verification
- Add delivery status tracking
Step 6: Real-Time Events
- Implement SSE endpoint
- Implement WebSocket handler
- Add heartbeat mechanism
- Add reconnection logic
Passing Criteria
Documentation
- [x] All REST endpoints documented via OpenAPI 3.1 spec
- [x] gRPC .proto contracts defined
- [x] GraphQL schema with documentation
Performance
- [x] REST P95 latency < 100ms (local)
- [x] gRPC P95 latency < 20ms (local)
- [x] Response compression enabled (gzip, brotli)
- [x] ETag caching implemented
API Versioning
- [x] API versioning supports 2 major versions simultaneously
- [x] Deprecation headers (RFC 8594)
- [x] Migration guide available
GraphQL
- [x] DataLoader eliminates N+1 queries
- [x] Query depth limiting configured
- [x] Query cost analysis working
Reliability
- [x] Webhook delivery success rate > 99.9% with retry logic
- [x] Circuit breaker implemented in gateway
- [x] Health checks on all services
Security
- [x] Authentication on all endpoints
- [x] Webhook signature verification
- [x] Rate limiting configured
Project Structure
MultiProtocolApi/
├── src/
│ ├── Blog.Api/ # REST API
│ │ ├── Controllers/
│ │ ├── Models/
│ │ ├── Services/
│ │ └── Program.cs
│ ├── Orders.Grpc/ # gRPC Service
│ │ ├── Protos/
│ │ ├── Services/
│ │ └── Program.cs
│ ├── Search.GraphQL/ # GraphQL Service
│ │ ├── Types/
│ │ ├── Resolvers/
│ │ ├── DataLoaders/
│ │ └── Program.cs
│ ├── Gateway/ # YARP API Gateway
│ │ ├── appsettings.json
│ │ └── Program.cs
│ ├── Webhooks/ # Webhook Service
│ │ ├── Services/
│ │ └── Program.cs
│ └── Shared/
│ ├── Models/
│ └── Common/
├── tests/
│ ├── Blog.Api.Tests/
│ ├── Orders.Grpc.Tests/
│ ├── Search.GraphQL.Tests/
│ ├── Gateway.Tests/
│ └── Contract.Tests/ # Pact tests
└── docker-compose.ymlDocker Compose
version: '3.8'
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: multi_protocol_api
POSTGRES_USER: admin
POSTGRES_PASSWORD: secret
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
rabbitmq:
image: rabbitmq:3-management
ports:
- "5672:5672"
- "15672:15672"
blog-api:
build: ./src/Blog.Api
ports:
- "5001:8080"
depends_on:
- postgres
- redis
order-grpc:
build: ./src/Orders.Grpc
ports:
- "5003:8080"
depends_on:
- postgres
search-graphql:
build: ./src/Search.GraphQL
ports:
- "5004:8080"
depends_on:
- postgres
- redis
gateway:
build: ./src/Gateway
ports:
- "8080:8080"
depends_on:
- blog-api
- order-grpc
- search-graphql
volumes:
postgres_data:References
- [ASP.NET Core Web API Documentation](https://learn.microsoft.com/en-us/aspnet/core/web-api/)
- [gRPC for .NET Documentation](https://learn.microsoft.com/en-us/aspnet/core/grpc/)
- [Hot Chocolate Documentation](https://chillicream.com/docs/hotchocolate)
- [YARP Documentation](https://microsoft.github.io/reverse-proxy/)
- [OpenAPI Specification 3.1](https://spec.openapis.org/oas/v3.1.0)