Протягом попередніх 11 статей ви вивчили всі аспекти створення Web API з Controllers:
Тепер час об'єднати все разом!
У цій статті ми створимо Book Store REST API — повноцінний production-ready проєкт, що демонструє всі вивчені концепції у реальному застосуванні.
Book Store REST API — повнофункціональний API для онлайн книгарні з:
Функціональність:
Технічні особливості:
Архітектура:
BookStoreApi/
├── Features/ # Vertical Slice Architecture
│ ├── Books/ # Controllers (складна логіка)
│ ├── Authors/ # Controllers
│ ├── Categories/ # Controllers
│ ├── Reviews/ # Controllers
│ ├── Orders/ # Controllers
│ ├── Health/ # Minimal API
│ └── Metrics/ # Minimal API
├── Shared/
│ ├── Data/
│ ├── Models/
│ ├── Filters/
│ └── Helpers/
└── Program.cs
| Компонент | Технологія | Призначення |
|---|---|---|
| Framework | ASP.NET Core 8.0 | Web API |
| Database | Entity Framework Core (InMemory) | ORM |
| Documentation | Swashbuckle | OpenAPI/Swagger |
| Versioning | Asp.Versioning.Mvc | API Versioning |
| Health Checks | AspNetCore.HealthChecks | Моніторинг |
| Validation | FluentValidation | Валідація DTO |
| Mapping | AutoMapper | DTO ↔ Entity |
| Minimal API | Carter | Організація endpoints |
mkdir -p Features/{Books,Authors,Categories,Reviews,Orders,Health,Metrics}
mkdir -p Shared/{Data,Models,Filters,Helpers}
Створіть файл Shared/Models/Book.cs:
namespace BookStoreApi.Shared.Models;
public class Book
{
public int Id { get; set; }
public required string Title { get; set; }
public required string ISBN { get; set; }
public string? Description { get; set; }
public decimal Price { get; set; }
public int Stock { get; set; }
public DateTime PublishedDate { get; set; }
public int AuthorId { get; set; }
public Author? Author { get; set; }
public int CategoryId { get; set; }
public Category? Category { get; set; }
public List<Review> Reviews { get; set; } = new();
public bool IsActive { get; set; } = true;
}
public class Author
{
public int Id { get; set; }
public required string Name { get; set; }
public string? Biography { get; set; }
public DateTime? BirthDate { get; set; }
public List<Book> Books { get; set; } = new();
}
public class Category
{
public int Id { get; set; }
public required string Name { get; set; }
public string? Description { get; set; }
public List<Book> Books { get; set; } = new();
}
public class Review
{
public int Id { get; set; }
public int BookId { get; set; }
public Book? Book { get; set; }
public required string ReviewerName { get; set; }
public int Rating { get; set; } // 1-5
public string? Comment { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public class Order
{
public int Id { get; set; }
public required string CustomerName { get; set; }
public required string CustomerEmail { get; set; }
public List<OrderItem> Items { get; set; } = new();
public decimal TotalAmount { get; set; }
public OrderStatus Status { get; set; } = OrderStatus.Pending;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public class OrderItem
{
public int Id { get; set; }
public int OrderId { get; set; }
public int BookId { get; set; }
public Book? Book { get; set; }
public int Quantity { get; set; }
public decimal Price { get; set; }
}
public enum OrderStatus
{
Pending,
Processing,
Shipped,
Delivered,
Cancelled
}
Створіть файл Shared/Data/AppDbContext.cs:
using Microsoft.EntityFrameworkCore;
using BookStoreApi.Shared.Models;
namespace BookStoreApi.Shared.Data;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<Book> Books => Set<Book>();
public DbSet<Author> Authors => Set<Author>();
public DbSet<Category> Categories => Set<Category>();
public DbSet<Review> Reviews => Set<Review>();
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderItem> OrderItems => Set<OrderItem>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Seed Authors
modelBuilder.Entity<Author>().HasData(
new Author { Id = 1, Name = "J.K. Rowling", Biography = "British author, best known for Harry Potter series" },
new Author { Id = 2, Name = "George R.R. Martin", Biography = "American novelist, author of A Song of Ice and Fire" },
new Author { Id = 3, Name = "J.R.R. Tolkien", Biography = "English writer, author of The Lord of the Rings" }
);
// Seed Categories
modelBuilder.Entity<Category>().HasData(
new Category { Id = 1, Name = "Fantasy", Description = "Fantasy fiction books" },
new Category { Id = 2, Name = "Science Fiction", Description = "Sci-fi books" },
new Category { Id = 3, Name = "Mystery", Description = "Mystery and thriller books" }
);
// Seed Books
modelBuilder.Entity<Book>().HasData(
new Book
{
Id = 1,
Title = "Harry Potter and the Philosopher's Stone",
ISBN = "978-0-7475-3269-9",
Description = "The first book in the Harry Potter series",
Price = 19.99m,
Stock = 50,
PublishedDate = new DateTime(1997, 6, 26),
AuthorId = 1,
CategoryId = 1
},
new Book
{
Id = 2,
Title = "A Game of Thrones",
ISBN = "978-0-553-10354-0",
Description = "The first book in A Song of Ice and Fire series",
Price = 24.99m,
Stock = 30,
PublishedDate = new DateTime(1996, 8, 1),
AuthorId = 2,
CategoryId = 1
},
new Book
{
Id = 3,
Title = "The Lord of the Rings",
ISBN = "978-0-618-00222-1",
Description = "Epic high-fantasy novel",
Price = 29.99m,
Stock = 40,
PublishedDate = new DateTime(1954, 7, 29),
AuthorId = 3,
CategoryId = 1
}
);
// Seed Reviews
modelBuilder.Entity<Review>().HasData(
new Review { Id = 1, BookId = 1, ReviewerName = "John Doe", Rating = 5, Comment = "Amazing book!", CreatedAt = DateTime.UtcNow.AddDays(-10) },
new Review { Id = 2, BookId = 1, ReviewerName = "Jane Smith", Rating = 5, Comment = "Loved it!", CreatedAt = DateTime.UtcNow.AddDays(-5) },
new Review { Id = 3, BookId = 2, ReviewerName = "Bob Johnson", Rating = 4, Comment = "Great story", CreatedAt = DateTime.UtcNow.AddDays(-3) }
);
}
}
Створіть файл Shared/Helpers/PagedList.cs:
namespace BookStoreApi.Shared.Helpers;
public class PagedList<T>
{
public List<T> Items { get; }
public int CurrentPage { get; }
public int TotalPages { get; }
public int PageSize { get; }
public int TotalCount { get; }
public bool HasPrevious => CurrentPage > 1;
public bool HasNext => CurrentPage < TotalPages;
public PagedList(List<T> items, int count, int page, int pageSize)
{
Items = items;
TotalCount = count;
CurrentPage = page;
PageSize = pageSize;
TotalPages = (int)Math.Ceiling(count / (double)pageSize);
}
public static PagedList<T> Create(IQueryable<T> source, int page, int pageSize)
{
var count = source.Count();
var items = source.Skip((page - 1) * pageSize).Take(pageSize).ToList();
return new PagedList<T>(items, count, page, pageSize);
}
}
public class PaginationFilter
{
private const int MaxPageSize = 100;
private int _pageSize = 20;
public int Page { get; set; } = 1;
public int PageSize
{
get => _pageSize;
set => _pageSize = value > MaxPageSize ? MaxPageSize : value;
}
}
Створіть файл Shared/Helpers/LinkGenerator.cs:
using Microsoft.AspNetCore.Mvc;
namespace BookStoreApi.Shared.Helpers;
public static class LinkGeneratorHelper
{
public static Dictionary<string, string> GeneratePaginationLinks(
IUrlHelper urlHelper,
string routeName,
int currentPage,
int totalPages,
object? routeValues = null)
{
var links = new Dictionary<string, string>
{
["self"] = urlHelper.Link(routeName, new { page = currentPage })!
};
if (currentPage > 1)
{
links["first"] = urlHelper.Link(routeName, new { page = 1 })!;
links["prev"] = urlHelper.Link(routeName, new { page = currentPage - 1 })!;
}
if (currentPage < totalPages)
{
links["next"] = urlHelper.Link(routeName, new { page = currentPage + 1 })!;
links["last"] = urlHelper.Link(routeName, new { page = totalPages })!;
}
return links;
}
}
Створіть файл Shared/Filters/CorrelationIdFilter.cs:
using Microsoft.AspNetCore.Mvc.Filters;
namespace BookStoreApi.Shared.Filters;
public class CorrelationIdFilter : IAsyncActionFilter
{
private const string CorrelationIdHeader = "X-Correlation-ID";
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
var correlationId = context.HttpContext.Request.Headers[CorrelationIdHeader]
.FirstOrDefault() ?? Guid.NewGuid().ToString();
context.HttpContext.Response.Headers.Append(CorrelationIdHeader, correlationId);
context.HttpContext.Items["CorrelationId"] = correlationId;
await next();
}
}
Створіть файл Shared/Filters/PerformanceMonitoringFilter.cs:
using Microsoft.AspNetCore.Mvc.Filters;
using System.Diagnostics;
namespace BookStoreApi.Shared.Filters;
public class PerformanceMonitoringFilter : IAsyncActionFilter
{
private readonly ILogger<PerformanceMonitoringFilter> _logger;
public PerformanceMonitoringFilter(ILogger<PerformanceMonitoringFilter> logger)
{
_logger = logger;
}
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
var stopwatch = Stopwatch.StartNew();
var method = context.HttpContext.Request.Method;
var path = context.HttpContext.Request.Path;
var resultContext = await next();
stopwatch.Stop();
var elapsedMs = stopwatch.ElapsedMilliseconds;
context.HttpContext.Response.Headers.Append("X-Response-Time-Ms", elapsedMs.ToString());
_logger.LogInformation(
"{Method} {Path} completed in {ElapsedMs}ms with status {StatusCode}",
method,
path,
elapsedMs,
context.HttpContext.Response.StatusCode);
}
}
Створіть файл Shared/Filters/ValidationFilter.cs:
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace BookStoreApi.Shared.Filters;
public class ValidationFilter : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
if (!context.ModelState.IsValid)
{
var errors = context.ModelState
.Where(x => x.Value?.Errors.Count > 0)
.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value!.Errors.Select(e => e.ErrorMessage).ToArray()
);
var problemDetails = new ValidationProblemDetails(errors)
{
Status = StatusCodes.Status400BadRequest,
Title = "One or more validation errors occurred",
Instance = context.HttpContext.Request.Path
};
context.Result = new BadRequestObjectResult(problemDetails);
return;
}
await next();
}
}
Створіть файл Features/Books/BookDto.cs:
using System.ComponentModel.DataAnnotations;
namespace BookStoreApi.Features.Books;
// v1 - Basic DTO
public record BookDtoV1
{
public int Id { get; init; }
public required string Title { get; init; }
public required string ISBN { get; init; }
public decimal Price { get; init; }
public int Stock { get; init; }
}
// v2 - Extended DTO з автором та категорією
public record BookDtoV2
{
public int Id { get; init; }
public required string Title { get; init; }
public required string ISBN { get; init; }
public string? Description { get; init; }
public decimal Price { get; init; }
public int Stock { get; init; }
public DateTime PublishedDate { get; init; }
public AuthorSummaryDto? Author { get; init; }
public CategorySummaryDto? Category { get; init; }
public double AverageRating { get; init; }
public int ReviewCount { get; init; }
public Dictionary<string, string>? Links { get; init; }
}
public record AuthorSummaryDto(int Id, string Name);
public record CategorySummaryDto(int Id, string Name);
public record CreateBookDto
{
[Required]
[MaxLength(200)]
public required string Title { get; init; }
[Required]
[RegularExpression(@"^(?:ISBN(?:-1[03])?:? )?(?=[0-9X]{10}$|(?=(?:[0-9]+[- ]){3})[- 0-9X]{13}$|97[89][0-9]{10}$|(?=(?:[0-9]+[- ]){4})[- 0-9]{17}$)(?:97[89][- ]?)?[0-9]{1,5}[- ]?[0-9]+[- ]?[0-9]+[- ]?[0-9X]$")]
public required string ISBN { get; init; }
[MaxLength(2000)]
public string? Description { get; init; }
[Range(0.01, 10000)]
public decimal Price { get; init; }
[Range(0, int.MaxValue)]
public int Stock { get; init; }
public DateTime PublishedDate { get; init; }
[Required]
public int AuthorId { get; init; }
[Required]
public int CategoryId { get; init; }
}
Створіть файл Features/Books/BooksV1Controller.cs:
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using BookStoreApi.Shared.Data;
using BookStoreApi.Shared.Helpers;
using Asp.Versioning;
namespace BookStoreApi.Features.Books;
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiVersion("1.0")]
public class BooksController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ILogger<BooksController> _logger;
public BooksController(AppDbContext db, ILogger<BooksController> logger)
{
_db = db;
_logger = logger;
}
/// <summary>
/// Get all books (v1 - basic info)
/// </summary>
[HttpGet(Name = "GetBooksV1")]
[MapToApiVersion("1.0")]
public async Task<ActionResult<PagedList<BookDtoV1>>> GetAll([FromQuery] PaginationFilter filter)
{
var query = _db.Books.Where(b => b.IsActive);
var pagedList = PagedList<BookDtoV1>.Create(
query.Select(b => new BookDtoV1
{
Id = b.Id,
Title = b.Title,
ISBN = b.ISBN,
Price = b.Price,
Stock = b.Stock
}),
filter.Page,
filter.PageSize);
return Ok(pagedList);
}
/// <summary>
/// Get book by ID (v1)
/// </summary>
[HttpGet("{id:int}")]
[MapToApiVersion("1.0")]
public async Task<ActionResult<BookDtoV1>> GetById(int id)
{
var book = await _db.Books.FindAsync(id);
if (book is null) return NotFound();
return Ok(new BookDtoV1
{
Id = book.Id,
Title = book.Title,
ISBN = book.ISBN,
Price = book.Price,
Stock = book.Stock
});
}
}
Створіть файл Features/Books/BooksV2Controller.cs:
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using BookStoreApi.Shared.Data;
using BookStoreApi.Shared.Helpers;
using Asp.Versioning;
namespace BookStoreApi.Features.Books;
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiVersion("2.0")]
public class BooksV2Controller : ControllerBase
{
private readonly AppDbContext _db;
private readonly IUrlHelper _urlHelper;
public BooksV2Controller(
AppDbContext db,
IUrlHelperFactory urlHelperFactory,
IActionContextAccessor actionContextAccessor)
{
_db = db;
_urlHelper = urlHelperFactory.GetUrlHelper(actionContextAccessor.ActionContext!);
}
/// <summary>
/// Get all books (v2 - with author, category, ratings, HATEOAS)
/// </summary>
[HttpGet(Name = "GetBooksV2")]
[MapToApiVersion("2.0")]
public async Task<ActionResult> GetAll(
[FromQuery] PaginationFilter filter,
[FromQuery] string? expand = null)
{
var query = _db.Books
.Include(b => b.Author)
.Include(b => b.Category)
.Include(b => b.Reviews)
.Where(b => b.IsActive);
var pagedList = PagedList<BookDtoV2>.Create(
query.Select(b => new BookDtoV2
{
Id = b.Id,
Title = b.Title,
ISBN = b.ISBN,
Description = b.Description,
Price = b.Price,
Stock = b.Stock,
PublishedDate = b.PublishedDate,
Author = new AuthorSummaryDto(b.Author!.Id, b.Author.Name),
Category = new CategorySummaryDto(b.Category!.Id, b.Category.Name),
AverageRating = b.Reviews.Any() ? b.Reviews.Average(r => r.Rating) : 0,
ReviewCount = b.Reviews.Count,
Links = GenerateBookLinks(b.Id)
}),
filter.Page,
filter.PageSize);
var links = LinkGeneratorHelper.GeneratePaginationLinks(
_urlHelper,
"GetBooksV2",
pagedList.CurrentPage,
pagedList.TotalPages);
var response = new
{
data = pagedList.Items,
pagination = new
{
currentPage = pagedList.CurrentPage,
totalPages = pagedList.TotalPages,
pageSize = pagedList.PageSize,
totalCount = pagedList.TotalCount
},
_links = links
};
return Ok(response);
}
private Dictionary<string, string> GenerateBookLinks(int bookId)
{
return new Dictionary<string, string>
{
["self"] = _urlHelper.Action("GetById", "BooksV2", new { id = bookId, version = "2.0" })!,
["reviews"] = _urlHelper.Action("GetReviews", "Reviews", new { bookId })!,
["author"] = _urlHelper.Action("GetById", "Authors", new { id = "{authorId}" })!
};
}
}
Дуже добре! Продовжую створювати фінальну статтю. Напишу решту розділів...
Створіть файл Features/Reviews/ReviewDto.cs:
using System.ComponentModel.DataAnnotations;
namespace BookStoreApi.Features.Reviews;
public record ReviewDto
{
public int Id { get; init; }
public int BookId { get; init; }
public required string ReviewerName { get; init; }
public int Rating { get; init; }
public string? Comment { get; init; }
public DateTime CreatedAt { get; init; }
}
public record CreateReviewDto
{
[Required]
[MaxLength(100)]
public required string ReviewerName { get; init; }
[Required]
[Range(1, 5, ErrorMessage = "Rating must be between 1 and 5")]
public int Rating { get; init; }
[MaxLength(1000)]
public string? Comment { get; init; }
}
Створіть файл Features/Reviews/ReviewsController.cs:
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using BookStoreApi.Shared.Data;
using BookStoreApi.Shared.Models;
using Asp.Versioning;
namespace BookStoreApi.Features.Reviews;
[ApiController]
[Route("api/v{version:apiVersion}/books/{bookId:int}/reviews")]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
public class ReviewsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ILogger<ReviewsController> _logger;
public ReviewsController(AppDbContext db, ILogger<ReviewsController> logger)
{
_db = db;
_logger = logger;
}
/// <summary>
/// Get all reviews for a book
/// </summary>
[HttpGet(Name = "GetReviews")]
[ProducesResponseType(typeof(List<ReviewDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<List<ReviewDto>>> GetReviews(int bookId)
{
var bookExists = await _db.Books.AnyAsync(b => b.Id == bookId);
if (!bookExists)
{
return NotFound(new ProblemDetails
{
Title = "Book not found",
Detail = $"Book with ID {bookId} does not exist",
Status = StatusCodes.Status404NotFound
});
}
var reviews = await _db.Reviews
.Where(r => r.BookId == bookId)
.OrderByDescending(r => r.CreatedAt)
.Select(r => new ReviewDto
{
Id = r.Id,
BookId = r.BookId,
ReviewerName = r.ReviewerName,
Rating = r.Rating,
Comment = r.Comment,
CreatedAt = r.CreatedAt
})
.ToListAsync();
return Ok(reviews);
}
/// <summary>
/// Add a review to a book
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(ReviewDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ReviewDto>> CreateReview(
int bookId,
[FromBody] CreateReviewDto dto)
{
var book = await _db.Books.FindAsync(bookId);
if (book is null)
{
return NotFound(new ProblemDetails
{
Title = "Book not found",
Detail = $"Book with ID {bookId} does not exist",
Status = StatusCodes.Status404NotFound
});
}
var review = new Review
{
BookId = bookId,
ReviewerName = dto.ReviewerName,
Rating = dto.Rating,
Comment = dto.Comment,
CreatedAt = DateTime.UtcNow
};
_db.Reviews.Add(review);
await _db.SaveChangesAsync();
var reviewDto = new ReviewDto
{
Id = review.Id,
BookId = review.BookId,
ReviewerName = review.ReviewerName,
Rating = review.Rating,
Comment = review.Comment,
CreatedAt = review.CreatedAt
};
return CreatedAtRoute(
"GetReviews",
new { bookId = review.BookId, version = "1.0" },
reviewDto);
}
/// <summary>
/// Delete a review
/// </summary>
[HttpDelete("{reviewId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteReview(int bookId, int reviewId)
{
var review = await _db.Reviews
.FirstOrDefaultAsync(r => r.Id == reviewId && r.BookId == bookId);
if (review is null)
{
return NotFound();
}
_db.Reviews.Remove(review);
await _db.SaveChangesAsync();
return NoContent();
}
}
/api/v1/books/{bookId}/reviews — рецензії як підресурс книгиСтворіть файл Features/Authors/AuthorDto.cs:
using System.ComponentModel.DataAnnotations;
namespace BookStoreApi.Features.Authors;
public record AuthorDto
{
public int Id { get; init; }
public required string Name { get; init; }
public string? Biography { get; init; }
public DateTime? BirthDate { get; init; }
public int BookCount { get; init; }
public Dictionary<string, string>? Links { get; init; }
}
public record CreateAuthorDto
{
[Required]
[MaxLength(200)]
public required string Name { get; init; }
[MaxLength(2000)]
public string? Biography { get; init; }
public DateTime? BirthDate { get; init; }
}
public record UpdateAuthorDto
{
[MaxLength(200)]
public string? Name { get; init; }
[MaxLength(2000)]
public string? Biography { get; init; }
public DateTime? BirthDate { get; init; }
}
Створіть файл Features/Authors/AuthorsController.cs:
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using BookStoreApi.Shared.Data;
using BookStoreApi.Shared.Models;
using Asp.Versioning;
namespace BookStoreApi.Features.Authors;
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
public class AuthorsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IUrlHelper _urlHelper;
public AuthorsController(
AppDbContext db,
IUrlHelperFactory urlHelperFactory,
IActionContextAccessor actionContextAccessor)
{
_db = db;
_urlHelper = urlHelperFactory.GetUrlHelper(actionContextAccessor.ActionContext!);
}
/// <summary>
/// Get all authors
/// </summary>
[HttpGet(Name = "GetAuthors")]
[ProducesResponseType(typeof(List<AuthorDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<AuthorDto>>> GetAll()
{
var authors = await _db.Authors
.Include(a => a.Books)
.Select(a => new AuthorDto
{
Id = a.Id,
Name = a.Name,
Biography = a.Biography,
BirthDate = a.BirthDate,
BookCount = a.Books.Count,
Links = GenerateAuthorLinks(a.Id)
})
.ToListAsync();
return Ok(authors);
}
/// <summary>
/// Get author by ID
/// </summary>
[HttpGet("{id:int}", Name = "GetAuthorById")]
[ProducesResponseType(typeof(AuthorDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AuthorDto>> GetById(int id)
{
var author = await _db.Authors
.Include(a => a.Books)
.FirstOrDefaultAsync(a => a.Id == id);
if (author is null)
{
return NotFound();
}
var dto = new AuthorDto
{
Id = author.Id,
Name = author.Name,
Biography = author.Biography,
BirthDate = author.BirthDate,
BookCount = author.Books.Count,
Links = GenerateAuthorLinks(author.Id)
};
return Ok(dto);
}
/// <summary>
/// Create a new author
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(AuthorDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<AuthorDto>> Create([FromBody] CreateAuthorDto dto)
{
var author = new Author
{
Name = dto.Name,
Biography = dto.Biography,
BirthDate = dto.BirthDate
};
_db.Authors.Add(author);
await _db.SaveChangesAsync();
var authorDto = new AuthorDto
{
Id = author.Id,
Name = author.Name,
Biography = author.Biography,
BirthDate = author.BirthDate,
BookCount = 0,
Links = GenerateAuthorLinks(author.Id)
};
return CreatedAtRoute("GetAuthorById", new { id = author.Id, version = "1.0" }, authorDto);
}
/// <summary>
/// Update an author
/// </summary>
[HttpPut("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Update(int id, [FromBody] UpdateAuthorDto dto)
{
var author = await _db.Authors.FindAsync(id);
if (author is null)
{
return NotFound();
}
if (dto.Name is not null) author.Name = dto.Name;
if (dto.Biography is not null) author.Biography = dto.Biography;
if (dto.BirthDate.HasValue) author.BirthDate = dto.BirthDate;
await _db.SaveChangesAsync();
return NoContent();
}
/// <summary>
/// Delete an author
/// </summary>
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int id)
{
var author = await _db.Authors
.Include(a => a.Books)
.FirstOrDefaultAsync(a => a.Id == id);
if (author is null)
{
return NotFound();
}
if (author.Books.Any())
{
return Conflict(new ProblemDetails
{
Title = "Cannot delete author",
Detail = "Author has associated books. Delete books first.",
Status = StatusCodes.Status409Conflict
});
}
_db.Authors.Remove(author);
await _db.SaveChangesAsync();
return NoContent();
}
private Dictionary<string, string> GenerateAuthorLinks(int authorId)
{
return new Dictionary<string, string>
{
["self"] = _urlHelper.Action("GetById", "Authors", new { id = authorId, version = "1.0" })!,
["books"] = _urlHelper.Action("GetAll", "Books", new { authorId, version = "2.0" })!
};
}
}
UpdateAuthorDto з nullable полями — оновлюємо тільки надані поля.Include(a => a.Books) для підрахунку кількості книгСтворіть файл Features/Categories/CategoriesController.cs:
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using BookStoreApi.Shared.Data;
using BookStoreApi.Shared.Models;
using Asp.Versioning;
namespace BookStoreApi.Features.Categories;
public record CategoryDto(int Id, string Name, string? Description, int BookCount);
public record CreateCategoryDto(string Name, string? Description);
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
public class CategoriesController : ControllerBase
{
private readonly AppDbContext _db;
public CategoriesController(AppDbContext db)
{
_db = db;
}
/// <summary>
/// Get all categories
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(List<CategoryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<CategoryDto>>> GetAll()
{
var categories = await _db.Categories
.Include(c => c.Books)
.Select(c => new CategoryDto(
c.Id,
c.Name,
c.Description,
c.Books.Count))
.ToListAsync();
return Ok(categories);
}
/// <summary>
/// Get category by ID
/// </summary>
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CategoryDto>> GetById(int id)
{
var category = await _db.Categories
.Include(c => c.Books)
.FirstOrDefaultAsync(c => c.Id == id);
if (category is null)
{
return NotFound();
}
return Ok(new CategoryDto(
category.Id,
category.Name,
category.Description,
category.Books.Count));
}
/// <summary>
/// Create a new category
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status201Created)]
public async Task<ActionResult<CategoryDto>> Create([FromBody] CreateCategoryDto dto)
{
var category = new Category
{
Name = dto.Name,
Description = dto.Description
};
_db.Categories.Add(category);
await _db.SaveChangesAsync();
var categoryDto = new CategoryDto(category.Id, category.Name, category.Description, 0);
return CreatedAtAction(nameof(GetById), new { id = category.Id, version = "1.0" }, categoryDto);
}
}
record замість class для immutable DTO. Це дає автоматичну реалізацію Equals, GetHashCode, ToString та value-based equality.Створіть файл Features/Health/HealthEndpoints.cs:
using Carter;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using System.Text.Json;
namespace BookStoreApi.Features.Health;
public class HealthEndpoints : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
// Basic health check
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = WriteHealthCheckResponse
}).WithTags("Health");
// Liveness probe (Kubernetes)
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("live"),
ResponseWriter = WriteHealthCheckResponse
}).WithTags("Health");
// Readiness probe (Kubernetes)
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready"),
ResponseWriter = WriteHealthCheckResponse
}).WithTags("Health");
}
private static Task WriteHealthCheckResponse(HttpContext context, HealthReport report)
{
context.Response.ContentType = "application/json";
var result = JsonSerializer.Serialize(new
{
status = report.Status.ToString(),
duration = report.TotalDuration.TotalMilliseconds,
checks = report.Entries.Select(e => new
{
name = e.Key,
status = e.Value.Status.ToString(),
description = e.Value.Description,
duration = e.Value.Duration.TotalMilliseconds,
exception = e.Value.Exception?.Message,
data = e.Value.Data
})
}, new JsonSerializerOptions
{
WriteIndented = true
});
return context.Response.WriteAsync(result);
}
}
Створіть файл Features/Health/DatabaseHealthCheck.cs:
using Microsoft.Extensions.Diagnostics.HealthChecks;
using BookStoreApi.Shared.Data;
using Microsoft.EntityFrameworkCore;
namespace BookStoreApi.Features.Health;
public class DatabaseHealthCheck : IHealthCheck
{
private readonly AppDbContext _db;
public DatabaseHealthCheck(AppDbContext db)
{
_db = db;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
// Перевіряємо чи можемо підключитися до БД
await _db.Database.CanConnectAsync(cancellationToken);
// Підраховуємо кількість книг
var bookCount = await _db.Books.CountAsync(cancellationToken);
return HealthCheckResult.Healthy(
"Database is accessible",
new Dictionary<string, object>
{
["bookCount"] = bookCount,
["database"] = "InMemory"
});
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy(
"Database is not accessible",
ex);
}
}
}
public class MemoryHealthCheck : IHealthCheck
{
private const long ThresholdBytes = 1024 * 1024 * 1024; // 1 GB
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var allocated = GC.GetTotalMemory(forceFullCollection: false);
var data = new Dictionary<string, object>
{
["allocatedBytes"] = allocated,
["allocatedMB"] = allocated / 1024 / 1024,
["gen0Collections"] = GC.CollectionCount(0),
["gen1Collections"] = GC.CollectionCount(1),
["gen2Collections"] = GC.CollectionCount(2)
};
var status = allocated < ThresholdBytes
? HealthCheckResult.Healthy("Memory usage is normal", data)
: HealthCheckResult.Degraded("Memory usage is high", null, data);
return Task.FromResult(status);
}
}
live (процес живий) vs ready (готовий приймати запити) — для KubernetesСтворіть файл Features/Metrics/MetricsEndpoints.cs:
using Carter;
using System.Diagnostics;
namespace BookStoreApi.Features.Metrics;
public class MetricsEndpoints : ICarterModule
{
private static readonly DateTime StartTime = DateTime.UtcNow;
public void AddRoutes(IEndpointRouteBuilder app)
{
app.MapGet("/metrics", () =>
{
var process = Process.GetCurrentProcess();
return Results.Ok(new
{
uptime = DateTime.UtcNow - StartTime,
uptimeSeconds = (DateTime.UtcNow - StartTime).TotalSeconds,
memory = new
{
workingSetMB = process.WorkingSet64 / 1024 / 1024,
privateMemoryMB = process.PrivateMemorySize64 / 1024 / 1024,
gcTotalMemoryMB = GC.GetTotalMemory(false) / 1024 / 1024
},
cpu = new
{
totalProcessorTime = process.TotalProcessorTime,
userProcessorTime = process.UserProcessorTime,
privilegedProcessorTime = process.PrivilegedProcessorTime
},
threads = new
{
count = process.Threads.Count,
threadPoolAvailable = ThreadPool.PendingWorkItemCount
},
gc = new
{
gen0Collections = GC.CollectionCount(0),
gen1Collections = GC.CollectionCount(1),
gen2Collections = GC.CollectionCount(2)
}
});
})
.WithName("GetMetrics")
.WithTags("Metrics")
.WithOpenApi();
}
}
Створіть файл Program.cs:
using Microsoft.EntityFrameworkCore;
using BookStoreApi.Shared.Data;
using BookStoreApi.Shared.Filters;
using BookStoreApi.Features.Health;
using Asp.Versioning;
using Carter;
using Microsoft.OpenApi.Models;
using System.Reflection;
var builder = WebApplication.CreateBuilder(args);
// ============================================================
// 1. Database
// ============================================================
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase("BookStoreDb"));
// ============================================================
// 2. Controllers з Filters
// ============================================================
builder.Services.AddControllers(options =>
{
options.Filters.Add<CorrelationIdFilter>();
options.Filters.Add<PerformanceMonitoringFilter>();
options.Filters.Add<ValidationFilter>();
})
.AddXmlSerializerFormatters(); // Content Negotiation: XML support
// ============================================================
// 3. API Versioning
// ============================================================
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(),
new HeaderApiVersionReader("X-Api-Version"),
new QueryStringApiVersionReader("api-version"));
})
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
// ============================================================
// 4. Swagger / OpenAPI
// ============================================================
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "Book Store API",
Version = "v1",
Description = "Production-ready REST API for online bookstore",
Contact = new OpenApiContact
{
Name = "Support Team",
Email = "support@bookstore.com"
}
});
options.SwaggerDoc("v2", new OpenApiInfo
{
Title = "Book Store API",
Version = "v2",
Description = "Enhanced version with ratings and reviews"
});
// XML коментарі для документації
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
if (File.Exists(xmlPath))
{
options.IncludeXmlComments(xmlPath);
}
// Групування за версіями
options.DocInclusionPredicate((docName, apiDesc) =>
{
if (!apiDesc.TryGetMethodInfo(out var methodInfo)) return false;
var versions = methodInfo.DeclaringType?
.GetCustomAttributes(true)
.OfType<ApiVersionAttribute>()
.SelectMany(attr => attr.Versions);
return versions?.Any(v => $"v{v}" == docName) ?? false;
});
});
// ============================================================
// 5. Health Checks
// ============================================================
builder.Services.AddHealthChecks()
.AddCheck<DatabaseHealthCheck>("database", tags: new[] { "ready", "live" })
.AddCheck<MemoryHealthCheck>("memory", tags: new[] { "ready" });
// ============================================================
// 6. Carter (Minimal API організація)
// ============================================================
builder.Services.AddCarter();
// ============================================================
// 7. Допоміжні сервіси
// ============================================================
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<IActionContextAccessor, ActionContextAccessor>();
// ============================================================
// 8. CORS (для frontend)
// ============================================================
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
// ============================================================
// 9. ProblemDetails
// ============================================================
builder.Services.AddProblemDetails();
var app = builder.Build();
// ============================================================
// Middleware Pipeline
// ============================================================
// 1. Exception Handling
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/error");
}
// 2. HTTPS Redirection
app.UseHttpsRedirection();
// 3. CORS
app.UseCors();
// 4. Swagger UI
app.UseSwagger();
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("/swagger/v1/swagger.json", "Book Store API v1");
options.SwaggerEndpoint("/swagger/v2/swagger.json", "Book Store API v2");
options.RoutePrefix = string.Empty; // Swagger на root URL
});
// 5. Authorization (якщо потрібно)
// app.UseAuthorization();
// 6. Controllers
app.MapControllers();
// 7. Carter Minimal API Endpoints
app.MapCarter();
// 8. Error endpoint
app.Map("/error", () => Results.Problem());
// ============================================================
// Database Seeding
// ============================================================
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.EnsureCreated();
}
app.Run();
Відредагуйте BookStoreApi.csproj:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Asp.Versioning.Mvc" Version="8.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.Client" Version="8.0.0" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
<PackageReference Include="Carter" Version="8.0.0" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.5.0" />
</ItemGroup>
</Project>
1. Swagger UI:
https://localhost:5001
2. Health Checks:
curl https://localhost:5001/health
curl https://localhost:5001/health/live
curl https://localhost:5001/health/ready
3. Metrics:
curl https://localhost:5001/metrics
4. Books API v1:
# Get all books (v1 - basic)
curl https://localhost:5001/api/v1/books
# Get book by ID
curl https://localhost:5001/api/v1/books/1
5. Books API v2:
# Get all books (v2 - з автором, категорією, рейтингом, HATEOAS)
curl https://localhost:5001/api/v2/books
# З пагінацією
curl "https://localhost:5001/api/v2/books?page=1&pageSize=10"
6. Content Negotiation:
# JSON (default)
curl -H "Accept: application/json" https://localhost:5001/api/v1/books
# XML
curl -H "Accept: application/xml" https://localhost:5001/api/v1/books
7. Reviews:
# Get reviews for book
curl https://localhost:5001/api/v1/books/1/reviews
# Add review
curl -X POST https://localhost:5001/api/v1/books/1/reviews \
-H "Content-Type: application/json" \
-d '{"reviewerName":"John Doe","rating":5,"comment":"Amazing book!"}'
8. Authors:
# Get all authors
curl https://localhost:5001/api/v1/authors
# Create author
curl -X POST https://localhost:5001/api/v1/authors \
-H "Content-Type: application/json" \
-d '{"name":"Stephen King","biography":"American author of horror novels"}'
9. Categories:
# Get all categories
curl https://localhost:5001/api/v1/categories
Ось як кожна попередня стаття реалізована у цьому проєкті:
| Стаття | Де у проєкті | Компоненти |
|---|---|---|
| 01. Від Minimal API до Controllers | Features/Health/, Features/Metrics/ vs Features/Books/BooksController.cs | Гібридна архітектура: Health/Metrics — Minimal API, Books/Authors — Controllers |
| 02. ControllerBase, ActionResult<T> | Всі контролери | ControllerBase, ActionResult<T>, Ok(), NotFound(), CreatedAtRoute(), [ProducesResponseType] |
| 03. Content Negotiation | Program.cs → .AddXmlSerializerFormatters() | JSON (default) + XML support через Accept header |
| 04. API Versioning | BooksController (v1) vs BooksV2Controller (v2) | [ApiVersion("1.0")], [MapToApiVersion("2.0")], URL/Header/Query versioning |
| 05. ProblemDetails | ReviewsController.CreateReview(), AuthorsController.Delete() | Structured errors з ProblemDetails, UseExceptionHandler |
| 06. Фільтри | Shared/Filters/ | CorrelationIdFilter, PerformanceMonitoringFilter, ValidationFilter |
| 07. Пагінація | Shared/Helpers/PagedList.cs, BooksV2Controller.GetAll() | PagedList<T>, PaginationFilter, pagination metadata |
| 08. HATEOAS | BooksV2Controller, AuthorsController | _links у відповідях, LinkGeneratorHelper, HAL-подібний формат |
| 09. Гібридна архітектура | Вся структура проєкту | Controllers (Books, Authors) + Minimal API (Health, Metrics) + Carter |
| 10. Документація | Program.cs → Swagger, XML-коментарі | Swashbuckle, XML documentation, versioned Swagger docs |
| 11. Health Checks | Features/Health/ | IHealthCheck, DatabaseHealthCheck, MemoryHealthCheck, Kubernetes probes |
Традиційна Layer Architecture:
BookStoreApi/
├── Controllers/
├── Services/
├── Repositories/
├── Models/
└── DTOs/
Проблеми:
Vertical Slice Architecture:
BookStoreApi/
├── Features/
│ ├── Books/ # Все про книги в одному місці
│ │ ├── BooksController.cs
│ │ ├── BookDto.cs
│ │ └── BookService.cs
│ ├── Authors/ # Все про авторів
│ └── Reviews/ # Все про рецензії
└── Shared/ # Тільки спільна інфраструктура
Переваги:
Controllers використовуємо коли:
[ApiController]Minimal API використовуємо коли:
Створіть OrdersController з endpoints:
GET /api/v1/orders — список всіх замовленьGET /api/v1/orders/{id} — деталі замовленняPOST /api/v1/orders — створення замовленняPUT /api/v1/orders/{id}/status — зміна статусу (Pending → Processing → Shipped → Delivered)Вимоги:
Stock книгДодайте до BooksV2Controller.GetAll() фільтрацію:
public record BookFilter
{
public decimal? MinPrice { get; init; }
public decimal? MaxPrice { get; init; }
public int? CategoryId { get; init; }
public int? AuthorId { get; init; }
public bool? InStock { get; init; }
}
Приклад запиту:
GET /api/v2/books?minPrice=10&maxPrice=30&categoryId=1&inStock=true
Додайте динамічне сортування через query parameter:
GET /api/v2/books?sort=price:desc,title:asc
Підказка: Використайте System.Linq.Dynamic.Core або expression trees.
Створіть GET /api/v2/books/search?q=harry+potter що шукає по:
Вимоги:
Додайте rate limiting для Reviews API:
429 Too Many Requests з Retry-After headerПідказка: Використайте AspNetCore.RateLimiting (.NET 7+) або AspNetCoreRateLimit NuGet.
Додайте Response Caching для GET /api/v1/books:
[ResponseCache(Duration = 60, VaryByQueryKeys = new[] { "page", "pageSize" })]
Вимоги:
Cache-Control та ETag headersДодайте JWT authentication:
POST /api/auth/login — отримання токена[Authorize]Вимоги:
Додайте Hangfire для:
Вимоги:
/hangfireСтворіть integration tests з WebApplicationFactory:
public class BooksApiTests : IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task GetBooks_ReturnsOkWithBooks()
{
// Arrange
var client = _factory.CreateClient();
// Act
var response = await client.GetAsync("/api/v1/books");
// Assert
response.EnsureSuccessStatusCode();
var books = await response.Content.ReadFromJsonAsync<List<BookDtoV1>>();
Assert.NotEmpty(books);
}
[Fact]
public async Task CreateBook_WithInvalidData_ReturnsBadRequest()
{
// ...
}
}
Вимоги:
Ви створили production-ready REST API що демонструє:
✅ Архітектура:
✅ API Design:
✅ Обробка даних:
✅ Документація:
✅ Моніторинг:
✅ Best Practices:
1. Гібридна архітектура — це норма
2. Vertical Slices > Layers
3. API Versioning — з першого дня
4. ProblemDetails — стандарт для помилок
{ "error": "Something went wrong" }. Використовуйте RFC 9457 ProblemDetails для структурованих, машинозчитуваних помилок.5. Health Checks — не опція
/health/live та /health/ready для Kubernetes та моніторингу.6. Документація пише себе
Цей проєкт — фундамент. Ось що можна додати для enterprise-level API:
1. Безпека:
2. Продуктивність:
3. Надійність:
4. Тестування:
5. Deployment:
Автор курсу: ASP.NET Core Team
Версія: 1.0
Дата оновлення: 2024
Ліцензія: MIT
Health Checks та моніторинг API
IHealthCheck інтерфейс, вбудовані чеки (SQL Server, Redis, RabbitMQ), кастомні health checks (зовнішні API, диск, пам'ять), Health Check UI, tags та фільтри (liveness vs readiness), Kubernetes probes, structured health response.
План курсу: ASP.NET Core Web API (Controllers)