مقالات › ASP.NET Core

آموزش ساخت REST API با ASP.NET Core از صفر

آموزش قدم‌به‌قدم ساخت یک Web API حرفه‌ای با ASP.NET Core: ساختار پروژه، کنترلر، DTO، EF Core، اعتبارسنجی، کدهای وضعیت HTTP و مستندسازی با Swagger.

ASP.NET Core یکی از سریع‌ترین و کامل‌ترین فریم‌ورک‌ها برای ساخت Web API است. در این آموزش یک API محصولات کوچک اما واقعی را از یک پوشه‌ی خالی تا یک سرویس تمیز، مستند و آماده‌ی انتشار می‌سازیم و در هر مرحله توضیح می‌دهیم چرا هر بخش وجود دارد.

پیش‌نیازها

  • ‎.NET 8 SDK یا نسخه‌های جدیدتر
  • یک ویرایشگر: Visual Studio، Rider یا VS Code
  • آشنایی پایه با C# (کلاس‌ها و async/await)

۱. ساخت پروژه

dotnet new webapi -n Shop.Api --use-controllers
cd Shop.Api
dotnet run

این قالب یک Program.cs، یک کنترلر نمونه و تنظیمات Swagger می‌سازد. آدرس https://localhost:5001/swagger را باز کنید تا مستندات تعاملی همه‌ی Endpointها را ببینید.

نکته: گزینه‌ی --use-controllers کد مبتنی بر کنترلر تولید می‌کند. بدون آن، قالب‌های جدید از Minimal API استفاده می‌کنند. هر دو خوب‌اند؛ اما کنترلرها برای APIهای بزرگ با Endpointها و فیلترهای زیاد ساختار بهتری دارند.

۲. Program.cs را بشناسید

Program.cs دو کار انجام می‌دهد: ثبت سرویس‌ها در Dependency Injection و پیکربندی Pipeline درخواست‌ها.

var builder = WebApplication.CreateBuilder(args);

// ۱) سرویس‌ها
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// ۲) Pipeline میان‌افزارها (ترتیب مهم است!)
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.Run();

هر چیزی قبل از builder.Build() پیکربندی است و هر چیزی بعد از آن مشخص می‌کند هر درخواست چطور از برنامه عبور کند.

۳. مدل‌سازی دامنه

یک پوشه‌ی Entities بسازید و کلاس Product را اضافه کنید:

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string? Description { get; set; }
    public decimal Price { get; set; }
    public int Stock { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

۴. اضافه کردن Entity Framework Core

dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Design
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
    public DbSet<Product> Products => Set<Product>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>(e =>
        {
            e.Property(p => p.Name).HasMaxLength(150).IsRequired();
            e.Property(p => p.Price).HasPrecision(18, 2);
        });
    }
}

آن را در Program.cs ثبت کنید و Connection String را به appsettings.json اضافه کنید:

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
{
  "ConnectionStrings": {
    "Default": "Server=.;Database=ShopDb;Trusted_Connection=True;TrustServerCertificate=True"
  }
}

سپس دیتابیس را بسازید:

dotnet tool install --global dotnet-ef
dotnet ef migrations add InitialCreate
dotnet ef database update

۵. هرگز Entity را مستقیم برنگردانید؛ از DTO استفاده کنید

برگرداندن مستقیم Entityها، ساختار دیتابیس را به قرارداد عمومی API گره می‌زند. یک ستون جدید ناگهان در همه‌ی پاسخ‌ها ظاهر می‌شود و کاربر حتی می‌تواند فیلدهایی را بفرستد که نباید کنترلشان کند (مثل CreatedAt). DTOها (Data Transfer Object) این مشکل را حل می‌کنند:

public record ProductDto(int Id, string Name, string? Description, decimal Price, bool InStock);

public record CreateProductRequest(
    [property: Required, MaxLength(150)] string Name,
    string? Description,
    [property: Range(0.01, 1_000_000)] decimal Price,
    [property: Range(0, int.MaxValue)] int Stock);

نوع record در C# برای این کار عالی است: کوتاه، تغییرناپذیر و با مقایسه‌ی مقداری.

۶. نوشتن کنترلر

[ApiController]
[Route("api/products")]
public class ProductsController(AppDbContext db) : ControllerBase
{
    [HttpGet]
    public async Task<ActionResult<List<ProductDto>>> GetAll(
        [FromQuery] string? search, CancellationToken ct)
    {
        var query = db.Products.AsNoTracking();
        if (!string.IsNullOrWhiteSpace(search))
            query = query.Where(p => p.Name.Contains(search));

        return await query
            .OrderBy(p => p.Name)
            .Select(p => new ProductDto(p.Id, p.Name, p.Description, p.Price, p.Stock > 0))
            .ToListAsync(ct);
    }

    [HttpGet("{id:int}")]
    public async Task<ActionResult<ProductDto>> GetById(int id, CancellationToken ct)
    {
        var product = await db.Products.AsNoTracking()
            .Where(p => p.Id == id)
            .Select(p => new ProductDto(p.Id, p.Name, p.Description, p.Price, p.Stock > 0))
            .FirstOrDefaultAsync(ct);

        return product is null ? NotFound() : product;
    }

    [HttpPost]
    public async Task<ActionResult<ProductDto>> Create(CreateProductRequest request, CancellationToken ct)
    {
        var product = new Product
        {
            Name = request.Name,
            Description = request.Description,
            Price = request.Price,
            Stock = request.Stock
        };

        db.Products.Add(product);
        await db.SaveChangesAsync(ct);

        var dto = new ProductDto(product.Id, product.Name, product.Description, product.Price, product.Stock > 0);
        return CreatedAtAction(nameof(GetById), new { id = product.Id }, dto);
    }

    [HttpPut("{id:int}")]
    public async Task<IActionResult> Update(int id, CreateProductRequest request, CancellationToken ct)
    {
        var product = await db.Products.FindAsync([id], ct);
        if (product is null) return NotFound();

        product.Name = request.Name;
        product.Description = request.Description;
        product.Price = request.Price;
        product.Stock = request.Stock;

        await db.SaveChangesAsync(ct);
        return NoContent();
    }

    [HttpDelete("{id:int}")]
    public async Task<IActionResult> Delete(int id, CancellationToken ct)
    {
        var deleted = await db.Products.Where(p => p.Id == id).ExecuteDeleteAsync(ct);
        return deleted == 0 ? NotFound() : NoContent();
    }
}

چند نکته‌ی مهم در این کد:

  • [ApiController] اعتبارسنجی خودکار را فعال می‌کند: اگر درخواست با Data Annotationها نخواند، ASP.NET Core خودش 400 Bad Request را همراه با جزئیات خطا در قالب ProblemDetails برمی‌گرداند؛ دیگر نیازی به if (!ModelState.IsValid) نیست.
  • AsNoTracking() کوئری‌های فقط‌خواندنی را سریع‌تر می‌کند، چون EF Core Entityها را ردیابی نمی‌کند.
  • Projection با Select فقط ستون‌های لازم را از SQL Server می‌خواند.
  • {id:int} یک Route Constraint است: آدرس /api/products/abc اصلاً به این اکشن نمی‌رسد.
  • ExecuteDeleteAsync بدون بارگذاری Entity، با یک دستور SQL حذف را انجام می‌دهد.

۷. کد وضعیت درست برگردانید

عملیات موفقیت خطاهای رایج
GET لیست 200 OK —
GET با شناسه 200 OK 404 Not Found
POST 201 Created به‌همراه هدر Location 400 Bad Request
PUT 204 No Content (یا 200 همراه با بدنه) 400، 404
DELETE 204 No Content 404

کدهای وضعیت درست، API شما را برای توسعه‌دهنده‌های فرانت‌اند و ابزارهایی مثل Swagger قابل پیش‌بینی می‌کند.

۸. صفحه‌بندی (Pagination)

هیچ‌وقت یک لیست بی‌انتها برنگردانید. یک الگوی ساده و کاربردی:

public record PagedResult<T>(IReadOnlyList<T> Items, int Page, int PageSize, int TotalCount);

[HttpGet("paged")]
public async Task<PagedResult<ProductDto>> GetPaged(int page = 1, int pageSize = 20, CancellationToken ct = default)
{
    pageSize = Math.Clamp(pageSize, 1, 100);
    var query = db.Products.AsNoTracking().OrderBy(p => p.Id);

    var total = await query.CountAsync(ct);
    var items = await query
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .Select(p => new ProductDto(p.Id, p.Name, p.Description, p.Price, p.Stock > 0))
        .ToListAsync(ct);

    return new PagedResult<ProductDto>(items, page, pageSize, total);
}

۹. منطق را از کنترلر بیرون ببرید

با بزرگ شدن API، کنترلرها باید سبک بمانند: درخواست را بگیرند، یک سرویس را صدا بزنند و نتیجه را برگردانند. منطق بیزینس جایش در لایه‌ی سرویس است:

public interface IProductService
{
    Task<ProductDto?> GetAsync(int id, CancellationToken ct);
    Task<ProductDto> CreateAsync(CreateProductRequest request, CancellationToken ct);
}

builder.Services.AddScoped<IProductService, ProductService>();

این کار منطق را قابل استفاده‌ی مجدد و قابل تست می‌کند و اولین قدم به سمت معماری لایه‌ای یا Clean Architecture است.

۱۰. چک‌لیست انتشار (Production)

  • مدیریت سراسری خطا با UseExceptionHandler و ProblemDetails
  • احراز هویت (معمولاً JWT) و [Authorize] روی Endpointهای محافظت‌شده
  • CORS تنظیم‌شده برای دامنه‌ی فرانت‌اند
  • لاگ‌گیری ساخت‌یافته (Serilog یا لاگر داخلی)
  • Health Check با builder.Services.AddHealthChecks() و app.MapHealthChecks("/health")
  • محدودیت نرخ درخواست (Rate Limiting) برای Endpointهای عمومی
  • فعال بودن Swagger فقط در جاهای مناسب

جمع‌بندی

یک API کامل CRUD با ASP.NET Core ساختیم: Program.cs تمیز، EF Core همراه با Migration، استفاده از DTO به‌جای Entity، اعتبارسنجی خودکار با [ApiController]، کدهای وضعیت درست HTTP، صفحه‌بندی و مسیری برای رسیدن به لایه‌ی سرویس. این اصول پایه، چه API شما پنج Endpoint داشته باشد چه پانصد، ثابت می‌مانند.

پرسش‌های متداول

کنترلر بهتر است یا Minimal API؟

هر دو برای محیط واقعی آماده‌اند. Minimal API برای سرویس‌های کوچک و میکروسرویس‌ها عالی است؛ کنترلرها برای APIهای بزرگ ساختار بیشتری (فیلترها، قراردادها، گروه‌بندی) فراهم می‌کنند.

برای DTOها از AutoMapper استفاده کنم؟

در پروژه‌های کوچک، نگاشت دستی یا Projection با Select شفاف‌تر و سریع‌تر است. کتابخانه‌های نگاشت وقتی DTOهای مشابه زیادی دارید مفیدند؛ فقط منطق مهم را داخل پروفایل‌های نگاشت پنهان نکنید.

نسخه‌بندی API را چطور انجام دهم؟

از نسخه‌بندی در آدرس (/api/v1/products) یا پکیج Asp.Versioning.Mvc استفاده کنید. قبل از اینکه کاربر خارجی پیدا کنید، برای نسخه‌بندی برنامه داشته باشید.