Core API File Upload and Download
The BookStore API needs to support book cover images. This topic adds endpoints to upload image files to the server and download them on demand. File handling in ASP.NET Core uses IFormFile for uploads and file stream responses for downloads.
Understanding File Upload in Web API
When a client uploads a file, it uses a multipart/form-data request. This is different from a regular JSON request — it sends the file as binary data along with additional form fields.
POST /api/books/1/cover
Content-Type: multipart/form-data; boundary=----FormBoundary
------FormBoundary
Content-Disposition: form-data; name="file"; filename="clean-code.jpg"
Content-Type: image/jpeg
[binary image data]
------FormBoundary--
Step 1 – Create the Upload Folder
Images are stored in a dedicated folder inside the project. Add this folder to .gitignore to avoid committing uploaded files to source control.
BookStoreAPI/
├── Uploads/
│ └── BookCovers/ ← uploaded images go here
Step 2 – Update the Book Model
// Models/Book.cs
public class Book
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Author { get; set; } = string.Empty;
public decimal Price { get; set; }
public string Category { get; set; } = string.Empty;
public bool IsAvailable { get; set; }
public DateTime CreatedDate { get; set; }
public string? CoverImagePath { get; set; } // ← NEW: stores file path
}
Run a new migration after adding the field:
dotnet ef migrations add AddCoverImagePath
dotnet ef database update
Step 3 – Create the File Service
// Services/FileService.cs
namespace BookStoreAPI.Services
{
public interface IFileService
{
Task<string> SaveFileAsync(IFormFile file, string folder);
void DeleteFile(string filePath);
bool FileExists(string filePath);
}
public class FileService : IFileService
{
private readonly IWebHostEnvironment _env;
private readonly ILogger<FileService> _logger;
// Allowed file types for book covers
private readonly string[] _allowedExtensions = { ".jpg", ".jpeg", ".png", ".webp" };
private const long MaxFileSizeBytes = 5 * 1024 * 1024; // 5 MB
public FileService(IWebHostEnvironment env, ILogger<FileService> logger)
{
_env = env;
_logger = logger;
}
public async Task<string> SaveFileAsync(IFormFile file, string folder)
{
// Validate file size
if (file.Length > MaxFileSizeBytes)
throw new ArgumentException("File size exceeds the 5 MB limit.");
// Validate file extension
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!_allowedExtensions.Contains(extension))
throw new ArgumentException($"File type '{extension}' is not allowed. Allowed: {string.Join(", ", _allowedExtensions)}");
// Build the save path
var uploadFolder = Path.Combine(_env.ContentRootPath, "Uploads", folder);
Directory.CreateDirectory(uploadFolder); // create if it doesn't exist
// Generate a unique filename to prevent collisions
var uniqueFileName = $"{Guid.NewGuid()}{extension}";
var filePath = Path.Combine(uploadFolder, uniqueFileName);
// Save the file
using var stream = new FileStream(filePath, FileMode.Create);
await file.CopyToAsync(stream);
_logger.LogInformation("File saved: {FilePath}", filePath);
// Return the relative path for storing in the database
return Path.Combine("Uploads", folder, uniqueFileName);
}
public void DeleteFile(string filePath)
{
var fullPath = Path.Combine(_env.ContentRootPath, filePath);
if (File.Exists(fullPath))
{
File.Delete(fullPath);
_logger.LogInformation("File deleted: {FilePath}", fullPath);
}
}
public bool FileExists(string filePath)
{
var fullPath = Path.Combine(_env.ContentRootPath, filePath);
return File.Exists(fullPath);
}
}
}
Step 4 – Register the File Service
// Program.cs
builder.Services.AddScoped<IFileService, FileService>();
Step 5 – Add File Upload Endpoint to BooksController
// Controllers/V2/BooksController.cs
using Microsoft.AspNetCore.Http;
// POST /api/v2/books/1/cover — upload a book cover image
[HttpPost("{id:int}/cover")]
[Authorize(Roles = "Admin")]
[RequestSizeLimit(5_242_880)] // 5 MB request size limit
public async Task<IActionResult> UploadCover(int id, IFormFile file)
{
if (file == null || file.Length == 0)
return BadRequest("No file was uploaded.");
var book = await _bookService.GetByIdAsync(id);
if (book == null) return NotFound($"Book with Id {id} was not found.");
// Delete old cover if it exists
if (!string.IsNullOrEmpty(book.CoverImagePath))
_fileService.DeleteFile(book.CoverImagePath);
// Save the new cover
var savedPath = await _fileService.SaveFileAsync(file, "BookCovers");
// Update the book record with the new image path
await _bookService.UpdateCoverImageAsync(id, savedPath);
return Ok(new
{
message = "Cover image uploaded successfully.",
path = savedPath
});
}
// GET /api/v2/books/1/cover — download a book cover image
[HttpGet("{id:int}/cover")]
[AllowAnonymous]
public async Task<IActionResult> GetCover(int id)
{
var book = await _bookService.GetByIdAsync(id);
if (book == null) return NotFound("Book not found.");
if (string.IsNullOrEmpty(book.CoverImagePath) || !_fileService.FileExists(book.CoverImagePath))
return NotFound("Cover image not found for this book.");
var fullPath = Path.Combine(_env.ContentRootPath, book.CoverImagePath);
var extension = Path.GetExtension(fullPath).ToLowerInvariant();
var contentType = extension switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".webp" => "image/webp",
_ => "application/octet-stream"
};
var fileBytes = await System.IO.File.ReadAllBytesAsync(fullPath);
return File(fileBytes, contentType, $"book-{id}-cover{extension}");
}
// DELETE /api/v2/books/1/cover — remove a book cover image
[HttpDelete("{id:int}/cover")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> DeleteCover(int id)
{
var book = await _bookService.GetByIdAsync(id);
if (book == null) return NotFound("Book not found.");
if (string.IsNullOrEmpty(book.CoverImagePath))
return NotFound("No cover image to delete.");
_fileService.DeleteFile(book.CoverImagePath);
await _bookService.UpdateCoverImageAsync(id, null);
return NoContent();
}
Testing File Upload in Postman
Method: POST
URL: https://localhost:7001/api/v2/books/1/cover
Authorization: Bearer {admin-jwt-token}
Body: form-data
Key: file (type: File)
Value: [select clean-code.jpg from your computer]
Response: 200 OK
{
"message": "Cover image uploaded successfully.",
"path": "Uploads\\BookCovers\\3f7a4b12-...jpg"
}
Security Considerations for File Uploads
| Risk | Protection |
|---|---|
| Malicious file types (.exe, .php) | Whitelist allowed extensions only |
| Oversized uploads (DoS attack) | Enforce MaxFileSizeBytes limit |
| Path traversal attack | Use Guid as filename — never use the original filename as-is |
| Executable files disguised as images | Validate MIME type, not just extension |
| Serving uploaded files as web pages | Store files outside the wwwroot folder |
Enabling Static File Access
To serve uploaded files directly via a URL (without the download endpoint), configure the static files middleware:
// Program.cs — serve files from the Uploads folder
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(
Path.Combine(builder.Environment.ContentRootPath, "Uploads")),
RequestPath = "/uploads"
});
// Files are now accessible at:
// GET https://localhost:7001/uploads/BookCovers/3f7a4b12-....jpg
Key Points
- File uploads use
IFormFilein the action method and requiremultipart/form-datacontent type from the client. - Always validate file size and extension before saving — never trust the client's filename or content type header alone.
- Store files with a
Guid-based filename to prevent path traversal attacks and file name conflicts. - Store only the relative path in the database — reconstruct the full path at runtime using the environment's content root.
- For downloads, return the file with
File(bytes, contentType, fileName)which sets the correct Content-Type and Content-Disposition headers.
