· 22 min read
Part 4: Enhancing Your API - PDF File Handling, Pagination, and Robust Error Management
Okay, let’s dive into Part 4, where we’ll tackle file handling for our eLibrary’s PDFs and touch on more advanced API practices.
Astrowind Blog Post: Building a Modern Web App with ASP.NET Core & React
Part 4: Enhancing Your API - PDF File Handling, Pagination, and Robust Error Management
In Part 3, we secured our API using JWTs and role-based authorization. Now, we’ll enhance our eLibrary API by adding features crucial for managing book PDFs: file uploads and downloads. We’ll also look at implementing pagination for better performance with large datasets and improving our error handling.
4.1. Handling PDF File Uploads
For our eLibrary, users (likely Admins) will need to upload PDF files for books.
Strategy:
- The client (React app) will send the file as part of a
multipart/form-data
request. - The API will receive the file, save it to a designated location (e.g., a folder on the server or cloud storage like Azure Blob Storage / AWS S3). For simplicity, we’ll start with a local folder.
- The API will store the path or a unique identifier for the file in the
Book
entity’sPdfFilePath
property.
4.1.1. Configure File Storage Location
Let’s define a storage path in appsettings.json
.
Open appsettings.json
:
{
// ... (Logging, ConnectionStrings, JwtSettings)
"AppSettings": {
"AdminEmail": "[email protected]",
"AdminPassword": "Password123!",
"PdfStoragePath": "Uploads/PDFs" // Relative path within the web root or a dedicated content root
// Output (when read): "Uploads/PDFs"
}
}
This path will be relative to the application’s content root (where Program.cs
lives) or web root (wwwroot
) depending on how we resolve it. For files not directly served by the static file middleware, content root is often preferred.
4.1.2. Update DTOs for File Upload
When creating or updating a book, we might want to include the PDF file. We’ll modify CreateBookDto
and potentially create a new DTO for updating a book’s PDF.
Modify DTOs/CreateBookDto.cs
:
// eLibrary.API/DTOs/CreateBookDto.cs
using Microsoft.AspNetCore.Http; // For IFormFile
using System.ComponentModel.DataAnnotations;
namespace eLibrary.API.DTOs
{
public class CreateBookDto
{
[Required]
[StringLength(200)]
public string Title { get; set; } = string.Empty;
[StringLength(13, MinimumLength = 10)]
public string? Isbn { get; set; }
[Required]
public DateTime? PublishedDate { get; set; }
public string? Publisher { get; set; }
[Required]
public int? AuthorId { get; set; }
public IFormFile? PdfFile { get; set; } // For uploading the PDF
}
}
And DTOs/UpdateBookDto.cs
can also have IFormFile? PdfFile
if you want to allow updating the PDF along with other details. For simplicity, let’s assume UpdateBookDto
for now primarily updates metadata, and we might have a separate endpoint/DTO for just updating the PDF if needed. Or, include it here:
Modify DTOs/UpdateBookDto.cs
:
// eLibrary.API/DTOs/UpdateBookDto.cs
using Microsoft.AspNetCore.Http; // For IFormFile
using System.ComponentModel.DataAnnotations;
namespace eLibrary.API.DTOs
{
public class UpdateBookDto
{
[Required]
[StringLength(200)]
public string Title { get; set; } = string.Empty;
[StringLength(13, MinimumLength = 10)]
public string? Isbn { get; set; }
[Required]
public DateTime? PublishedDate { get; set; }
public string? Publisher { get; set; }
[Required]
public int? AuthorId { get; set; }
public IFormFile? PdfFile { get; set; } // Optional: For updating/replacing the PDF
}
}
4.1.3. Create a File Service (IFileService
)
This service will encapsulate the logic for saving and deleting files.
Create Services/IFileService.cs
:
// eLibrary.API/Services/IFileService.cs
using Microsoft.AspNetCore.Http;
using System.Threading.Tasks;
namespace eLibrary.API.Services
{
public interface IFileService
{
/// <summary>
/// Saves the uploaded file.
/// </summary>
/// <param name="file">The file to save.</param>
/// <param name="subDirectory">Optional subdirectory within the main storage path.</param>
/// <returns>The relative path to the saved file, or null if saving failed.</returns>
Task<string?> SaveFileAsync(IFormFile file, string? subDirectory = null);
/// <summary>
/// Deletes a file.
/// </summary>
/// <param name="filePath">The relative path to the file to delete.</param>
void DeleteFile(string? filePath);
/// <summary>
/// Gets the full physical path for a given relative file path.
/// </summary>
/// <param name="relativePath">The relative path from the storage root.</param>
/// <returns>The full physical path or null if the relative path is null/empty.</returns>
string? GetPhysicalPath(string? relativePath);
}
}
Create Services/LocalFileService.cs
(implementation for local storage):
// eLibrary.API/Services/LocalFileService.cs
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting; // For IWebHostEnvironment
using Microsoft.Extensions.Logging;
using System;
using System.IO;
using System.Threading.Tasks;
namespace eLibrary.API.Services
{
public class LocalFileService : IFileService
{
private readonly string _storageBasePath; // Full physical path
private readonly ILogger<LocalFileService> _logger;
private readonly string _relativeStoragePath; // Relative path from config
public LocalFileService(IConfiguration configuration, IWebHostEnvironment env, ILogger<LocalFileService> logger)
{
_logger = logger;
_relativeStoragePath = configuration["AppSettings:PdfStoragePath"] ?? "Uploads/PDFs";
// Output (_relativeStoragePath): "Uploads/PDFs"
// Resolve the base path. Using ContentRootPath for files not directly served by static files middleware.
// If you intend to serve them directly (e.g. from wwwroot), use env.WebRootPath.
_storageBasePath = Path.Combine(env.ContentRootPath, _relativeStoragePath);
// Output (_storageBasePath, example): "C:\\projects\\eLibrary\\eLibrary.API\\Uploads\\PDFs"
if (!Directory.Exists(_storageBasePath))
{
Directory.CreateDirectory(_storageBasePath);
_logger.LogInformation("Created storage directory: {Path}", _storageBasePath);
// Output (if directory didn't exist): Created storage directory: C:\projects\eLibrary\eLibrary.API\Uploads\PDFs
}
}
public async Task<string?> SaveFileAsync(IFormFile file, string? subDirectory = null)
{
if (file == null || file.Length == 0)
{
_logger.LogWarning("SaveFileAsync called with null or empty file.");
return null;
}
try
{
// Ensure target directory exists
var targetDirectory = _storageBasePath;
var relativeSubDirectory = string.Empty;
if (!string.IsNullOrWhiteSpace(subDirectory))
{
targetDirectory = Path.Combine(_storageBasePath, subDirectory);
relativeSubDirectory = subDirectory;
}
if (!Directory.Exists(targetDirectory))
{
Directory.CreateDirectory(targetDirectory);
_logger.LogInformation("Created subdirectory for file storage: {Path}", targetDirectory);
}
// Generate a unique file name to prevent overwrites and handle special characters
var extension = Path.GetExtension(file.FileName);
var uniqueFileName = $"{Guid.NewGuid()}{extension}";
// Output (uniqueFileName, example): "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.pdf"
var physicalFilePath = Path.Combine(targetDirectory, uniqueFileName);
// Output (physicalFilePath, example): "C:\\projects\\eLibrary\\eLibrary.API\\Uploads\\PDFs\\xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.pdf"
using (var stream = new FileStream(physicalFilePath, FileMode.Create))
{
await file.CopyToAsync(stream);
}
_logger.LogInformation("File saved successfully: {PhysicalPath}", physicalFilePath);
// Return the relative path (including subdirectory if present) for storage in DB
var relativeFilePath = Path.Combine(relativeSubDirectory, uniqueFileName).Replace('\\', '/');
// Output (relativeFilePath, example): "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.pdf" or "some_subdir/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.pdf"
return relativeFilePath;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving file {FileName}", file.FileName);
return null;
}
}
public void DeleteFile(string? relativeFilePath)
{
if (string.IsNullOrWhiteSpace(relativeFilePath))
{
_logger.LogWarning("DeleteFile called with null or empty file path.");
return;
}
try
{
var physicalFilePath = GetPhysicalPath(relativeFilePath);
if (physicalFilePath != null && File.Exists(physicalFilePath))
{
File.Delete(physicalFilePath);
_logger.LogInformation("File deleted successfully: {PhysicalPath}", physicalFilePath);
// Output: File deleted successfully: C:\projects\eLibrary\eLibrary.API\Uploads\PDFs\somefile.pdf
}
else
{
_logger.LogWarning("File not found for deletion or path invalid: {RelativePath}", relativeFilePath);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting file {RelativePath}", relativeFilePath);
}
}
public string? GetPhysicalPath(string? relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath)) return null;
return Path.Combine(_storageBasePath, relativePath.TrimStart('/', '\\'));
// Output (example): "C:\\projects\\eLibrary\\eLibrary.API\\Uploads\\PDFs\\somefile.pdf"
}
}
}
Register LocalFileService
: In Program.cs
:
// ...
builder.Services.AddScoped<IFileService, LocalFileService>();
// ...
4.1.4. Update BookService
to Handle File Uploads
Modify Services/BookService.cs
:
// eLibrary.API/Services/BookService.cs
// ... (other usings)
using eLibrary.API.Services; // For IFileService
using Microsoft.AspNetCore.Http; // For IFormFile
namespace eLibrary.API.Services
{
public class BookService : IBookService
{
private readonly IBookRepository _bookRepository;
private readonly IRepository<Author> _authorRepository;
private readonly IMapper _mapper;
private readonly ILogger<BookService> _logger;
private readonly IFileService _fileService; // Add this
public BookService(
IBookRepository bookRepository,
IRepository<Author> authorRepository,
IMapper mapper,
ILogger<BookService> logger,
IFileService fileService) // Inject IFileService
{
_bookRepository = bookRepository;
_authorRepository = authorRepository;
_mapper = mapper;
_logger = logger;
_fileService = fileService; // Store it
}
// ... (GetAllBooksAsync, GetBookByIdAsync remain the same)
public async Task<BookDto?> CreateBookAsync(CreateBookDto createBookDto) // DTO now has IFormFile
{
_logger.LogInformation("Attempting to create a new book with title: {BookTitle}", createBookDto.Title);
if (createBookDto.AuthorId.HasValue)
{
var authorExists = await _authorRepository.GetByIdAsync(createBookDto.AuthorId.Value);
if (authorExists == null)
{
_logger.LogWarning("Author with ID: {AuthorId} not found.", createBookDto.AuthorId);
return null;
}
}
else
{
_logger.LogWarning("AuthorId is required to create a book.");
return null;
}
var book = _mapper.Map<Book>(createBookDto);
// Handle file upload
if (createBookDto.PdfFile != null && createBookDto.PdfFile.Length > 0)
{
// Validate file type (optional but recommended)
if (createBookDto.PdfFile.ContentType != "application/pdf")
{
_logger.LogWarning("Invalid file type for PDF upload: {ContentType}. Expected application/pdf.", createBookDto.PdfFile.ContentType);
// You might want to return a specific error DTO or throw an exception
return null; // Or throw new ArgumentException("Invalid file type. Only PDF is allowed.");
}
var savedFilePath = await _fileService.SaveFileAsync(createBookDto.PdfFile);
if (string.IsNullOrEmpty(savedFilePath))
{
_logger.LogError("Failed to save uploaded PDF file for book: {BookTitle}", createBookDto.Title);
// Decide on error handling: proceed without PDF, or fail creation?
return null; // For now, fail if PDF upload fails
}
book.PdfFilePath = savedFilePath; // Store the relative path
_logger.LogInformation("PDF file saved at: {FilePath}", savedFilePath);
}
await _bookRepository.AddAsync(book);
var success = await _bookRepository.SaveChangesAsync() > 0;
if (success)
{
_logger.LogInformation("Book created successfully with ID: {BookId}", book.Id);
var createdBookWithDetails = await _bookRepository.GetBookWithAuthorAsync(book.Id);
return _mapper.Map<BookDto>(createdBookWithDetails);
}
_logger.LogError("Failed to save new book to the database.");
// If PDF was saved but DB save failed, we might have an orphaned file.
// Consider cleanup logic or transactional behavior if critical.
if (!string.IsNullOrEmpty(book.PdfFilePath))
{
_fileService.DeleteFile(book.PdfFilePath); // Attempt to clean up
}
return null;
}
public async Task<bool> UpdateBookAsync(int id, UpdateBookDto updateBookDto)
{
_logger.LogInformation("Attempting to update book with ID: {BookId}", id);
var bookToUpdate = await _bookRepository.GetByIdAsync(id);
if (bookToUpdate == null)
{
_logger.LogWarning("Book with ID: {BookId} not found for update.", id);
return false;
}
// ... (AuthorId validation as before) ...
string? oldPdfPath = bookToUpdate.PdfFilePath;
string? newPdfPath = null;
// Handle potential PDF file update
if (updateBookDto.PdfFile != null && updateBookDto.PdfFile.Length > 0)
{
if (updateBookDto.PdfFile.ContentType != "application/pdf")
{
_logger.LogWarning("Invalid file type for PDF update: {ContentType}. Expected application/pdf.", updateBookDto.PdfFile.ContentType);
return false; // Or throw
}
newPdfPath = await _fileService.SaveFileAsync(updateBookDto.PdfFile);
if (string.IsNullOrEmpty(newPdfPath))
{
_logger.LogError("Failed to save updated PDF file for book ID: {BookId}", id);
return false; // Fail update if new PDF save fails
}
bookToUpdate.PdfFilePath = newPdfPath;
_logger.LogInformation("New PDF file for book ID {BookId} saved at: {FilePath}", id, newPdfPath);
}
_mapper.Map(updateBookDto, bookToUpdate); // Apply other changes
// Ensure PdfFilePath isn't overwritten by mapper if PdfFile was null in DTO but a path existed
if (updateBookDto.PdfFile == null && !string.IsNullOrEmpty(oldPdfPath) && newPdfPath == null) {
bookToUpdate.PdfFilePath = oldPdfPath; // Keep old path if no new file was provided
}
_bookRepository.Update(bookToUpdate);
var success = await _bookRepository.SaveChangesAsync() > 0;
if (success)
{
_logger.LogInformation("Book with ID: {BookId} updated successfully.", id);
// If a new PDF was uploaded and successfully saved, delete the old one
if (!string.IsNullOrEmpty(newPdfPath) && !string.IsNullOrEmpty(oldPdfPath) && oldPdfPath != newPdfPath)
{
_fileService.DeleteFile(oldPdfPath);
}
}
else
{
_logger.LogError("Failed to save updates for book ID: {BookId}.", id);
// If update failed but a new PDF was saved, delete the newly uploaded PDF (orphan)
if (!string.IsNullOrEmpty(newPdfPath))
{
_fileService.DeleteFile(newPdfPath);
}
bookToUpdate.PdfFilePath = oldPdfPath; // Revert path if DB update failed
}
return success;
}
public async Task<bool> DeleteBookAsync(int id)
{
_logger.LogInformation("Attempting to delete book with ID: {BookId}", id);
var bookToDelete = await _bookRepository.GetByIdAsync(id);
if (bookToDelete == null)
{
_logger.LogWarning("Book with ID: {BookId} not found for deletion.", id);
return false;
}
string? pdfPathToDelete = bookToDelete.PdfFilePath;
_bookRepository.Remove(bookToDelete);
var success = await _bookRepository.SaveChangesAsync() > 0;
if (success)
{
_logger.LogInformation("Book with ID: {BookId} deleted successfully from DB.", id);
// If DB deletion was successful, delete the associated PDF file
if (!string.IsNullOrWhiteSpace(pdfPathToDelete))
{
_fileService.DeleteFile(pdfPathToDelete);
}
}
else
{
_logger.LogError("Failed to delete book with ID: {BookId} from the database.", id);
}
return success;
}
}
}
Important Considerations for BookService
File Handling:
- File Validation: Added a basic check for
application/pdf
. You might want more robust validation (magic numbers, virus scanning). - Error Handling & Cleanup:
- If PDF saves but DB save fails during
CreateBookAsync
, the orphaned PDF is deleted. - If a new PDF saves during
UpdateBookAsync
but DB save fails, the new PDF is deleted. - If DB update succeeds and a new PDF replaced an old one, the old PDF is deleted.
- If DB delete succeeds, the associated PDF is deleted. This logic is crucial to prevent orphaned files.
- If PDF saves but DB save fails during
- Transactionality: For critical systems, saving file metadata to DB and file to storage should ideally be transactional. This is complex with local file systems but more achievable with cloud storage services that offer transactional capabilities or robust eventing for cleanup.
4.1.5. Update BooksController
to Accept IFormFile
The controller actions for POST
and PUT
need to be marked with [FromForm]
if they are to receive IFormFile
. When a DTO contains IFormFile
, ASP.NET Core automatically expects multipart/form-data
.
Modify Controllers/BooksController.cs
:
// eLibrary.API/Controllers/BooksController.cs
// ...
// POST: api/books
[HttpPost]
[Authorize(Roles = "Admin")]
// When IFormFile is part of a DTO, [FromForm] is often implied for the DTO,
// but explicitly adding it to the DTO parameter can make intent clearer.
// However, for complex objects with IFormFile, model binding typically handles it.
// If createBookDto was a simple type, you'd need [FromForm] on the IFormFile parameter directly.
public async Task<ActionResult<BookDto>> PostBook([FromForm] CreateBookDto createBookDto) // Use [FromForm]
{
_logger.LogInformation("API endpoint called: POST api/books with title: {BookTitle}", createBookDto.Title);
if (createBookDto.PdfFile != null)
{
_logger.LogInformation("PDF File received: {FileName}, Size: {FileSize} bytes, ContentType: {ContentType}",
createBookDto.PdfFile.FileName, createBookDto.PdfFile.Length, createBookDto.PdfFile.ContentType);
// Output (if file uploaded): PDF File received: my_book.pdf, Size: 102400 bytes, ContentType: application/pdf
}
if (!ModelState.IsValid)
{
// ... (handle invalid model state)
return BadRequest(ModelState);
}
var createdBook = await _bookService.CreateBookAsync(createBookDto);
if (createdBook == null)
{
_logger.LogError("Book creation failed for title: {BookTitle}. Possible reasons: author not found, PDF type invalid, or save error.", createBookDto.Title);
return BadRequest("Book creation failed. Check logs for details (e.g., author existence, PDF type/save error).");
}
return CreatedAtAction(nameof(GetBook), new { id = createdBook.Id }, createdBook);
}
// PUT: api/books/5
[HttpPut("{id}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> PutBook(int id, [FromForm] UpdateBookDto updateBookDto) // Use [FromForm]
{
_logger.LogInformation("API endpoint called: PUT api/books/{BookId}", id);
if (updateBookDto.PdfFile != null)
{
_logger.LogInformation("PDF File received for update: {FileName}, Size: {FileSize} bytes, ContentType: {ContentType}",
updateBookDto.PdfFile.FileName, updateBookDto.PdfFile.Length, updateBookDto.PdfFile.ContentType);
}
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var success = await _bookService.UpdateBookAsync(id, updateBookDto);
if (!success)
{
return NotFound($"Book with ID {id} not found or update failed (e.g. PDF issue, author issue).");
}
return NoContent();
}
// ...
Testing File Upload with Swagger UI: Swagger UI supports IFormFile
and will render a file upload control when you “Try it out” for the POST /api/books
or PUT /api/books/{id}
endpoints.
- Run the API.
- Authorize Swagger with an Admin user’s token.
- Go to
POST /api/books
. Click “Try it out”. - Fill in the book details (Title, AuthorId, etc.).
- For the
pdfFile
field, a “Choose File” button will appear. Select a PDF file. (Imagine a screenshot of Swagger UI’s POST /api/books section with form fields and a “Choose File” button for pdfFile) - Click “Execute”.
- Check the
Uploads/PDFs
folder in youreLibrary.API
project directory. You should see the uniquely named PDF file. - The response should be a 201 Created, and the
bookDto
in the response body should have apdfFilePath
like"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.pdf"
.
4.2. Handling PDF File Downloads
Now we need an endpoint to serve these PDFs. We should ensure only authorized users can download them.
4.2.1. Add Download Endpoint to BooksController
// eLibrary.API/Controllers/BooksController.cs
// ... (other usings)
using System.IO; // For FileStream, Path
using Microsoft.AspNetCore.StaticFiles; // For FileExtensionContentTypeProvider
namespace eLibrary.API.Controllers
{
// ... ([Authorize] at controller level still applies)
public class BooksController : ControllerBase
{
private readonly IBookService _bookService;
private readonly IFileService _fileService; // Inject IFileService
private readonly ILogger<BooksController> _logger;
public BooksController(IBookService bookService, IFileService fileService, ILogger<BooksController> logger)
{
_bookService = bookService;
_fileService = fileService;
_logger = logger;
}
// ... (other actions)
// GET: api/books/{id}/pdf
[HttpGet("{id}/pdf")]
[Authorize] // General authorization, could be more specific (e.g., only "User" or "Admin")
public async Task<IActionResult> DownloadBookPdf(int id)
{
_logger.LogInformation("API endpoint called: GET api/books/{BookId}/pdf (Authorized)", id);
var book = await _bookService.GetBookByIdAsync(id); // Use service to get book DTO
if (book == null || string.IsNullOrWhiteSpace(book.PdfFilePath))
{
_logger.LogWarning("PDF not found for book ID: {BookId}. Book or PDF path missing.", id);
return NotFound("PDF not available for this book.");
// Output (HTTP 404 Not Found): "PDF not available for this book."
}
var physicalPath = _fileService.GetPhysicalPath(book.PdfFilePath);
if (physicalPath == null || !System.IO.File.Exists(physicalPath))
{
_logger.LogError("Physical PDF file not found at path: {PhysicalPath} for book ID: {BookId}, though DB has path: {DbPath}", physicalPath, id, book.PdfFilePath);
return NotFound("PDF file is missing on the server.");
// Output (HTTP 404 Not Found): "PDF file is missing on the server."
}
// Determine content type
var provider = new FileExtensionContentTypeProvider();
if (!provider.TryGetContentType(physicalPath, out var contentType))
{
contentType = "application/octet-stream"; // Default if type not found
_logger.LogWarning("Could not determine content type for {PhysicalPath}. Using default.", physicalPath);
}
// Output (contentType for a PDF): "application/pdf"
// To suggest a filename to the browser (optional, but good UX)
// Could use book.Title + ".pdf" but ensure it's sanitized.
// For simplicity, using the stored filename.
var downloadName = Path.GetFileName(physicalPath); // Or a more user-friendly name
_logger.LogInformation("Serving PDF: {PhysicalPath} with ContentType: {ContentType} as {DownloadName}", physicalPath, contentType, downloadName);
return PhysicalFile(physicalPath, contentType, downloadName); // Enables 'Save As' in browser
// Output (HTTP 200 OK, with headers):
// Content-Type: application/pdf
// Content-Disposition: attachment; filename="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.pdf"; filename*=UTF-8''xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.pdf
// (The actual PDF file bytes in the body)
}
}
}
Key points for Download:
[Authorize]
: Ensures only authenticated users can attempt downloads. You could add role checks if needed (e.g., only subscribed users)._fileService.GetPhysicalPath()
: Uses our file service to resolve the relative path from the DB to an absolute physical path.FileExtensionContentTypeProvider
: Helps determine the correct MIME type for theContent-Type
header.PhysicalFile(path, contentType, downloadName)
: Efficiently streams the file.downloadName
suggests a filename to the browser if the user chooses to save the file. This sets theContent-Disposition: attachment; filename="name.pdf"
header. If you omitdownloadName
, it might default toContent-Disposition: inline
, trying to display in the browser.
Testing PDF Download:
- Upload a PDF to a book.
- Get the ID of that book.
- Authorize Swagger with a valid user token.
- Execute
GET /api/books/{id}/pdf
. - Your browser should either display the PDF (if it has a PDF viewer and
Content-Disposition
is inline or not strongly set to attachment) or prompt you to download a file named likexxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.pdf
.
4.3. Pagination for Book Lists
When you have many books, returning all of them in GET /api/books
is inefficient. We need pagination.
4.3.1. Create Helper for Paged Lists
Create Helpers/PagedList.cs
:
// eLibrary.API/Helpers/PagedList.cs
using Microsoft.EntityFrameworkCore; // For ToListAsync
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace eLibrary.API.Helpers
{
public class PagedList<T> : List<T> // Inherits from List<T> to easily use its methods
{
public int CurrentPage { get; private set; }
public int TotalPages { get; private set; }
public int PageSize { get; private set; }
public int TotalCount { get; private set; }
public bool HasPrevious => CurrentPage > 1;
public bool HasNext => CurrentPage < TotalPages;
public PagedList(List<T> items, int count, int pageNumber, int pageSize)
{
TotalCount = count;
PageSize = pageSize;
CurrentPage = pageNumber;
TotalPages = (int)Math.Ceiling(count / (double)pageSize);
AddRange(items); // Add the items for the current page to this PagedList instance
}
public static async Task<PagedList<T>> CreateAsync(IQueryable<T> source, int pageNumber, int pageSize)
{
var count = await source.CountAsync();
// Output (count, example): 150 (total number of books matching query)
var items = await source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToListAsync();
// Output (items, example for pageNumber=1, pageSize=10): The first 10 books.
return new PagedList<T>(items, count, pageNumber, pageSize);
}
}
// Parameter class for pagination query
public class PagingParams
{
private const int MaxPageSize = 50;
public int PageNumber { get; set; } = 1;
private int _pageSize = 10;
public int PageSize
{
get => _pageSize;
set => _pageSize = (value > MaxPageSize) ? MaxPageSize : value;
}
}
// Specific params for Books, can inherit PagingParams and add filtering/sorting
public class BookParams : PagingParams
{
public string? TitleFilter { get; set; }
public int? AuthorIdFilter { get; set; }
public string? OrderBy { get; set; } // e.g., "title_asc", "publishedDate_desc"
}
}
4.3.2. Update IBookRepository
and BookRepository
Modify Repositories/IBookRepository.cs
:
// ...
using eLibrary.API.Helpers; // For PagedList and BookParams
public interface IBookRepository : IRepository<Book>
{
// ... (existing methods)
Task<PagedList<Book>> GetBooksAsync(BookParams bookParams); // New method
}
Modify Repositories/BookRepository.cs
:
// ...
using eLibrary.API.Helpers;
using System.Linq; // For IQueryable
public class BookRepository : Repository<Book>, IBookRepository
{
public BookRepository(ApplicationDbContext context) : base(context) { }
// ... (existing methods)
public async Task<PagedList<Book>> GetBooksAsync(BookParams bookParams)
{
var query = _dbSet.Include(b => b.Author).AsQueryable(); // Start with IQueryable and include Author
// Apply filtering
if (bookParams.AuthorIdFilter.HasValue)
{
query = query.Where(b => b.AuthorId == bookParams.AuthorIdFilter.Value);
}
if (!string.IsNullOrWhiteSpace(bookParams.TitleFilter))
{
query = query.Where(b => b.Title.ToLower().Contains(bookParams.TitleFilter.ToLower()));
}
// Apply sorting (basic example, can be made more robust)
if (!string.IsNullOrWhiteSpace(bookParams.OrderBy))
{
switch (bookParams.OrderBy.ToLower())
{
case "title_asc":
query = query.OrderBy(b => b.Title);
break;
case "title_desc":
query = query.OrderByDescending(b => b.Title);
break;
case "publisheddate_desc":
query = query.OrderByDescending(b => b.PublishedDate);
break;
default:
query = query.OrderBy(b => b.Title); // Default sort
break;
}
}
else
{
query = query.OrderBy(b => b.Title); // Default sort if not specified
}
return await PagedList<Book>.CreateAsync(query, bookParams.PageNumber, bookParams.PageSize);
// Output: A PagedList<Book> instance containing the items for the current page and pagination metadata.
}
}
4.3.3. Update IBookService
and BookService
Modify Services/IBookService.cs
:
// ...
using eLibrary.API.Helpers;
public interface IBookService
{
// Task<IEnumerable<BookDto>> GetAllBooksAsync(); // Old method, can be replaced or kept for internal use
Task<PagedList<BookDto>> GetBooksAsync(BookParams bookParams); // New paginated method
// ... (other methods)
}
Modify Services/BookService.cs
:
// ...
using eLibrary.API.Helpers;
public class BookService : IBookService
{
// ... (constructor and other methods)
// Remove or comment out old GetAllBooksAsync
// public async Task<IEnumerable<BookDto>> GetAllBooksAsync() ...
public async Task<PagedList<BookDto>> GetBooksAsync(BookParams bookParams)
{
_logger.LogInformation("Fetching paged books with params: {@BookParams}", bookParams);
var booksFromRepo = await _bookRepository.GetBooksAsync(bookParams);
// The booksFromRepo is a PagedList<Book>
// We need to map the items List<Book> inside PagedList<Book> to List<BookDto>
// And create a new PagedList<BookDto> with the same pagination metadata.
var bookDtos = _mapper.Map<List<BookDto>>(booksFromRepo);
var pagedBookDtos = new PagedList<BookDto>(
bookDtos,
booksFromRepo.TotalCount,
booksFromRepo.CurrentPage,
booksFromRepo.PageSize
);
// Output: A PagedList<BookDto> instance.
return pagedBookDtos;
}
// ...
}
4.3.4. Update BooksController
to Use Pagination
Modify Controllers/BooksController.cs
:
// ...
using eLibrary.API.Helpers; // For PagingParams, BookParams, and AddPaginationHeader extension
namespace eLibrary.API.Controllers
{
// ...
public class BooksController : ControllerBase
{
// ...
// GET: api/books
[HttpGet]
[AllowAnonymous] // Example: Make book listing public, adjust as needed
public async Task<ActionResult<IEnumerable<BookDto>>> GetBooks([FromQuery] BookParams bookParams) // Bind from query string
{
_logger.LogInformation("API endpoint called: GET api/books (Paginated) with params: {@BookParams}", bookParams);
// Output (bookParams with defaults): { PageNumber = 1, PageSize = 10, TitleFilter = null, AuthorIdFilter = null, OrderBy = null }
// Output (bookParams with query ?pageNumber=2&pageSize=5): { PageNumber = 2, PageSize = 5, ... }
var pagedBooks = await _bookService.GetBooksAsync(bookParams);
// Add pagination info to the response header for the client
Response.AddPaginationHeader(pagedBooks.CurrentPage, pagedBooks.PageSize,
pagedBooks.TotalCount, pagedBooks.TotalPages);
// Output (Response Headers, example):
// Pagination: {"currentPage":1,"itemsPerPage":10,"totalItems":150,"totalPages":15}
// Access-Control-Expose-Headers: Pagination (if CORS is enabled and you want client to read it)
return Ok(pagedBooks); // The list itself is returned in the body
// Output (Body, HTTP 200 OK):
// [
// { "id": 1, "title": "Book A...", ... },
// ... (up to 10 books for the current page)
// ]
}
// ...
}
}
We also need an extension method to add the pagination header. Create Helpers/HttpExtensions.cs
:
// eLibrary.API/Helpers/HttpExtensions.cs
using Microsoft.AspNetCore.Http;
using System.Text.Json;
namespace eLibrary.API.Helpers
{
public static class HttpExtensions
{
public static void AddPaginationHeader(this HttpResponse response, int currentPage, int itemsPerPage, int totalItems, int totalPages)
{
var paginationHeader = new
{
currentPage,
itemsPerPage,
totalItems,
totalPages
};
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
response.Headers.Append("Pagination", JsonSerializer.Serialize(paginationHeader, options));
// Important for CORS if your client is on a different domain
response.Headers.Append("Access-Control-Expose-Headers", "Pagination");
}
}
}
Testing Pagination:
- Run the API.
- Try
GET /api/books
. You should get the first page with default page size. Check thePagination
header in the response (e.g., using browser dev tools or Postman). - Try
GET /api/books?pageNumber=2&pageSize=5&titleFilter=the&orderBy=publishedDate_desc
. You should get the second page, 5 items per page, filtered by title containing “the”, and sorted.
4.4. Global Error Handling Middleware
Instead of try-catch blocks in every controller action for unexpected errors, a global error handling middleware is cleaner.
Create Middleware/ExceptionMiddleware.cs
:
// eLibrary.API/Middleware/ExceptionMiddleware.cs
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Hosting; // For IHostEnvironment
using Microsoft.Extensions.Logging;
using System;
using System.Net;
using System.Text.Json;
using System.Threading.Tasks;
namespace eLibrary.API.Middleware
{
public class ExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionMiddleware> _logger;
private readonly IHostEnvironment _env;
public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger, IHostEnvironment env)
{
_next = next;
_logger = logger;
_env = env;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
// Output (if no exception): Request proceeds to next middleware/endpoint.
}
catch (Exception ex)
{
_logger.LogError(ex, "An unhandled exception occurred: {ErrorMessage}", ex.Message);
// Output (console/logs): An unhandled exception occurred: Some error message. [Stack Trace]
context.Response.ContentType = "application/json";
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; // 500
var response = _env.IsDevelopment()
? new ApiExceptionResponse((int)HttpStatusCode.InternalServerError, ex.Message, ex.StackTrace?.ToString())
: new ApiExceptionResponse((int)HttpStatusCode.InternalServerError, "An internal server error occurred.");
// Output (Development Response Body, HTTP 500):
// { "statusCode": 500, "message": "Actual error message", "details": "Stack trace..." }
// Output (Production Response Body, HTTP 500):
// { "statusCode": 500, "message": "An internal server error occurred.", "details": null }
var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
var json = JsonSerializer.Serialize(response, options);
await context.Response.WriteAsync(json);
}
}
}
// Simple DTO for consistent API error responses
public class ApiExceptionResponse
{
public int StatusCode { get; set; }
public string Message { get; set; }
public string? Details { get; set; } // Stack trace in dev
public ApiExceptionResponse(int statusCode, string message, string? details = null)
{
StatusCode = statusCode;
Message = message;
Details = details;
}
}
}
Register Middleware in Program.cs
: This middleware should be one of the first in the pipeline to catch exceptions from later middleware/endpoints.
// eLibrary.API/Program.cs
// ...
using eLibrary.API.Middleware; // For ExceptionMiddleware
var app = builder.Build();
// Configure the HTTP request pipeline.
app.UseMiddleware<ExceptionMiddleware>(); // Add this early in the pipeline
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
// The developer exception page is still useful for very early startup errors before our middleware runs
}
else
{
// app.UseExceptionHandler("/Error"); // Default error handler, can be removed if custom middleware is robust
app.UseHsts();
}
// ... (HttpsRedirection, UseRouting, UseCors, UseAuthentication, UseAuthorization)
app.MapControllers();
app.Run();
Now, if an unhandled exception occurs anywhere in your request processing (after this middleware), it will be caught, logged, and a standardized JSON error response will be sent.
Example of Testing Global Error Handler: Temporarily throw an exception in one of your controller methods:
// In BooksController.cs, for testing GetBook:
[HttpGet("{id}")]
public async Task<ActionResult<BookDto>> GetBook(int id)
{
throw new InvalidOperationException("Test unhandled exception!"); // Temporary
// ...
}
When you call GET /api/books/{id}
, you should receive a 500 Internal Server Error with the JSON structure defined in ApiExceptionResponse
. The console/log output will show the full exception. Remember to remove the test exception.
This part covered quite a bit: PDF uploads/downloads, pagination for lists, and a global exception handler for more robust API behavior. These are essential features for a production-ready API.
Next Up (Part 5 Preview - Final API Touches & React Prep):
- CORS (Cross-Origin Resource Sharing) configuration for React frontend.
- API Versioning (brief overview).
- Setting up a basic Health Check endpoint.
- Final review of conventions and best practices before moving to React.
- Brief on what the React app will need from the API (endpoints, DTOs).
Almost ready to switch gears to the frontend!