· 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:

  1. The client (React app) will send the file as part of a multipart/form-data request.
  2. 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.
  3. The API will store the path or a unique identifier for the file in the Book entity’s PdfFilePath 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.
  • 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.

  1. Run the API.
  2. Authorize Swagger with an Admin user’s token.
  3. Go to POST /api/books. Click “Try it out”.
  4. Fill in the book details (Title, AuthorId, etc.).
  5. 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)
  6. Click “Execute”.
  7. Check the Uploads/PDFs folder in your eLibrary.API project directory. You should see the uniquely named PDF file.
  8. The response should be a 201 Created, and the bookDto in the response body should have a pdfFilePath 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 the Content-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 the Content-Disposition: attachment; filename="name.pdf" header. If you omit downloadName, it might default to Content-Disposition: inline, trying to display in the browser.

Testing PDF Download:

  1. Upload a PDF to a book.
  2. Get the ID of that book.
  3. Authorize Swagger with a valid user token.
  4. Execute GET /api/books/{id}/pdf.
  5. 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 like xxxxxxxx-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:

  1. Run the API.
  2. Try GET /api/books. You should get the first page with default page size. Check the Pagination header in the response (e.g., using browser dev tools or Postman).
  3. 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!

Back to Blog