Skip to content

บทที่ 1 — REST API แรก (Minimal + Controller)

← บทที่ 0 | สารบัญ | บทที่ 2: Database + EF Core →

บทนี้สร้าง CRUD API ครบทั้ง 4 การกระทำ — Create (สร้าง), Read (อ่าน), Update (แก้), Delete (ลบ) — เขียนทั้งแบบ Minimal API และแบบ Controller

เราจะเขียน Minimal API ก่อน (สั้นกว่า เหมาะเรียน) แล้วค่อยเทียบกับ Controller (ยาวกว่าแต่ feature เยอะกว่า) ในหัวข้อ 9

อธิบายทุก attribute (ป้ายกำกับที่แปะบนโค้ด เช่น [HttpGet]) และทุก method ที่ใช้


1. โครงสร้าง

บทนี้จะสร้าง CRUD API ตัวจริง — เริ่มจากวางโครงโปรเจกต์ให้เป็นระเบียบก่อน แยกเป็น Models, Dtos, Services, Endpoints การแยกชั้นแบบนี้ทำให้โค้ดโตได้โดยไม่รก ตามผังด้านล่าง:

bash
dotnet new webapi -n UsersApi --use-program-main
cd UsersApi

อธิบาย flag ที่ใช้:

  • -n UsersApi = ตั้งชื่อโปรเจกต์เป็น UsersApi
  • --use-program-main = ใช้ Program.Main แบบ classic (มี method Main ชัดเจน) แทน top-level statement แบบสั้น เห็นโครงสร้างชัดกว่าเวลาขยายแอป
  • ตั้งแต่ .NET 9 เป็นต้นมา template webapi เป็น Minimal API by default อยู่แล้ว — ไม่ต้องใส่ flag เพิ่ม (เราจะลอง Minimal API ก่อนใน §4 แล้วเทียบกับ Controller ใน §9)

💡 บทนี้ assume ความรู้จากเล่ม C#:

  • csharp/05 — OOP (class, interface, inheritance)
  • csharp/09 — LINQ (การ query ข้อมูลแบบ SQL ใน C#)
  • csharp/10 — Modern C# (record, pattern matching, collection expression)

ถ้ายังไม่ผ่าน เจอ record, required, is { } u, .Select(...) แล้วจะงง — มีกล่องอธิบายให้ตามจุดในบทนี้ แต่ถ้ามีเวลา แนะนำผ่านเล่ม C# มาก่อน

text
UsersApi/
├── Program.cs
├── Models/
│   └── User.cs
├── Dtos/
│   ├── CreateUserDto.cs
│   └── UpdateUserDto.cs
├── Services/
│   └── UsersService.cs
└── Endpoints/
    └── UsersEndpoints.cs   ← group endpoint

2. Model + DTO

เริ่มจากนิยามข้อมูล แยกเป็น 2 ฝั่ง:

  • Model คือ class ที่เป็นตัวแทนข้อมูลในแอป เช่น User คือข้อมูลของผู้ใช้คนหนึ่ง (บทที่ 2 จะสอนเชื่อมกับ database)
  • DTO (Data Transfer Object — วัตถุสำหรับรับ-ส่งข้อมูล) คือรูปแบบที่ใช้รับ-ส่งกับ client โดยแยกออกเป็น create/update/response

ทำไมต้องแยก model กับ DTO?

  1. กัน over-posting = การที่ client แอบส่ง field เกินมาแก้ข้อมูลที่ไม่ควรแก้ — เช่น ถ้าไม่แยก DTO client อาจส่ง {"name":"Alice","isAdmin":true} มา แล้ว user จะกลายเป็น admin ทันที
  2. ให้ API contract (ข้อตกลงรูปแบบข้อมูลที่ API รับ-ส่งกับ client) ไม่ผูกกับโครงสร้างภายในของ entity — แก้ entity ได้โดยไม่กระทบ client

💡 สั้น ๆ เรื่อง C# property: { get; set; } คือการประกาศ field ที่อ่านและเขียนได้ (เหมือน attribute ใน Python) — { get; init; } คือเขียนได้แค่ตอนสร้าง object ครั้งแรก เรียนเต็มในเล่ม csharp/บท OOP

csharp
// Models/User.cs
public class User
{
    public Guid Id { get; init; }  // Guid = รหัสไม่ซ้ำกันทั่วโลก รูปแบบ 8-4-4-4-12 hex เช่น "550e8400-e29b-41d4-a716-446655440000" — สร้างในโค้ดได้เลยโดยไม่ต้องรอ database เป็นคนออกเลขให้ (ต่างจาก id แบบตัวเลขไล่ 1,2,3,... ที่ปกติต้อง insert ลง DB ก่อนถึงจะรู้ค่า)
    public required string Name { get; set; }       // ⭐ required = compile error ถ้าใครสร้าง User ใหม่โดยไม่ตั้ง Name (C# 11+)
    public required string Email { get; set; }
    public DateTime CreatedAt { get; init; } = DateTime.UtcNow;  // ⚠️ ตั้ง default ใน C# ทำให้ทดสอบยาก — ใน production ควรตั้งใน service method หรือให้ DB set ด้วย DEFAULT NOW()
}

// Dtos/CreateUserDto.cs
using System.ComponentModel.DataAnnotations;

// 🆕 record = class แบบสั้น compiler generate constructor + Equals + ToString ให้
//    syntax (...) ในวงเล็บ = "primary constructor" — parameter กลายเป็น property อัตโนมัติ (C# 9+)
//
// ⚠️ ใช้ [property: ...] เพื่อให้ attribute ติดกับ "property" ที่ generate ออกมา
//    ไม่ใช่ติดกับ "parameter" — ถ้าไม่มี property: model binding + validation ของ ASP.NET Core
//    จะอ่าน attribute ไม่เจอ (validation จะไม่ทำงาน)
public record CreateUserDto(
    [property: Required, MinLength(2)] string Name,
    [property: Required, EmailAddress] string Email
);

public record UpdateUserDto(
    [property: MinLength(2)] string? Name,
    [property: EmailAddress] string? Email
);

public record UserResponse(Guid Id, string Name, string Email, DateTime CreatedAt);

💡 Id/CreatedAt ใช้ init; ไม่ใช่ set;init (C# 9) อนุญาตให้ตั้งค่าได้แค่ตอนสร้าง object เท่านั้น หลังจากนั้นแก้ไม่ได้ → ปลอดภัยกว่า set ที่แก้ได้ตลอด · entity model สมัยใหม่ควรเป็น immutable เท่าที่ทำได้ (เปลี่ยน state ผ่าน method หรือ with-expression)

💡 UpdateUserDto รับ nullable ทั้งคู่ = field ไหนเป็น null แปลว่า client ไม่อยากแก้ (partial update) · ถ้าต้องการ semantics ของ PATCH แบบเป๊ะ ๆ (เช่น แยก "ตั้งเป็น null" กับ "ไม่แก้") JSON Patch (RFC 6902) เป็นทางเลือกที่มาตรฐานกว่า แต่หนักกว่าสำหรับเคสง่าย

💡 ใหม่ในบทนี้ — feature ของ C# รุ่นใหม่ที่ csharp/05+10 เรียนเต็ม:

  • required (C# 11) = บังคับ caller ต้อง init property นี้ตอนสร้าง object — กัน "ลืม set" → ดู csharp/05
  • record (C# 9) = class แบบสั้นสำหรับ "value-like data" — มี Equals/ToString/Deconstruct generate ให้ → ดู csharp/10
  • [Required, MinLength(2)] = data annotation จาก System.ComponentModel.DataAnnotations — ASP.NET Core ใช้ validate input อัตโนมัติ
  • ⚠️ ต้องมี using System.ComponentModel.DataAnnotations; ที่บนไฟล์ ถ้าไม่มี compiler จะบอกว่า Required ไม่มีอยู่ (ไฟล์ตัวอย่างบนมี using นี้แล้ว บรรทัดที่ 2 ของ CreateUserDto.cs)

3. Service (in-memory ก่อน — บทถัดไปใส่ DB)

แยก business logic (ตรรกะธุรกิจ — กฎการทำงานจริงของแอป) ออกจาก endpoint ไว้ใน "service" — บทนี้เก็บข้อมูลใน memory (Dictionary) ก่อนเพื่อโฟกัสที่โครงสร้าง API แล้วบทถัดไปจะสลับไปใช้ database จริงโดยไม่ต้องแก้ endpoint การแยกชั้นแบบนี้ทำให้เปลี่ยน storage (ที่เก็บข้อมูล) ได้ง่าย:

csharp
// Services/UsersService.cs
using System.Collections.Concurrent;  // using = การ import namespace (กลุ่มของ class) เข้ามาใช้ — เหมือน import ใน Python หรือ require ใน Node

public class UsersService
{
    // ⚠️ ใช้ ConcurrentDictionary เพราะ service ตัวนี้ register เป็น Singleton (ดู §5)
    //    หมายความว่ามี instance เดียวที่หลาย request เรียกพร้อมกัน → ต้อง thread-safe
    //    Dictionary<K,V> ธรรมดาไม่ thread-safe → race condition + crash ได้
    //    ConcurrentDictionary<K,V> = HashMap ที่ปลอดภัยต่อ multi-thread
    private readonly ConcurrentDictionary<Guid, User> _store = new();

    public IEnumerable<User> List() => _store.Values;  // IEnumerable<User> = ลิสต์ User ที่วนซ้ำได้ — ยืดหยุ่นกว่า List<T> เพราะรับได้ทั้ง array, List, และ query result

    public User? Get(Guid id) => _store.GetValueOrDefault(id);

    public User Create(CreateUserDto dto)
    {
        var u = new User
        {
            Id = Guid.NewGuid(),
            Name = dto.Name,
            Email = dto.Email,
        };
        _store[u.Id] = u;
        return u;
    }

    public User? Update(Guid id, UpdateUserDto dto)
    {
        // เช็คก่อนว่ามี id จริงหรือไม่ (ถ้าไม่มี = NotFound)
        if (!_store.TryGetValue(id, out var existing)) return null;

        // lock ที่ตัว user เพื่อกัน race ตอนสองคนแก้ user เดียวกันพร้อมกัน
        // (ConcurrentDictionary กันได้แค่ระดับ dictionary — การแก้ property ของ entity ต้อง lock เอง)
        lock (existing)
        {
            if (dto.Name is not null) existing.Name = dto.Name;
            if (dto.Email is not null) existing.Email = dto.Email;
        }
        return existing;
    }

    public bool Delete(Guid id) => _store.TryRemove(id, out _);
}

⚠️ ทำไม ConcurrentDictionary ไม่ใช่ DictionaryUsersService register เป็น Singleton ใน DI (ดู §5) → ทุก request แชร์ instance เดียวกัน · Dictionary<K,V> ธรรมดา ไม่ thread-safe — ถ้าสอง request เขียน/อ่านพร้อมกัน อาจ corrupt state หรือ throw InvalidOperationException ตอน enumerate · ทางเลือกอื่นคือเปลี่ยนเป็น Scoped (แต่ข้อมูลจะหายทุก request — ทำลายจุดประสงค์ตัวอย่าง) หรือใช้ lock รอบทุก access (verbose กว่า)

💡 หมายเหตุสำหรับบทถัดไป: service นี้ register เป็น Singleton เพราะ ตัว store คือ Dictionary ในตัว — เมื่อสลับไปใช้ EF Core (บทที่ 2) ให้เปลี่ยนเป็น Scoped เพื่อให้ lifetime ตรงกับ DbContext (Singleton + DbContext = captive dependency bug)


4. Minimal API — Endpoints

ส่วนที่ผูกทุกอย่างเข้าด้วยกันคือ endpoint (จุดปลายทาง URL ที่ตอบ request) — ใช้ MapGroup จัดกลุ่ม route ภายใต้ /users แล้ว map แต่ละ HTTP method (GET/POST/PATCH/DELETE) ไปยัง handler ที่เรียก service

จุดสำคัญที่มือใหม่งง — DI auto-inject ใน Minimal API:

  • เมื่อเราเขียน (UsersService svc) => ใน lambda, ASP.NET Core จะค้นใน DI container และ "ฉีด" (inject) instance ของ UsersService ให้เราอัตโนมัติ — เราไม่ต้อง new UsersService() เอง
  • lambda parameter ที่ type ตรงกับ service ที่ register ไว้ใน builder.Services (ดู §5)
  • svc ไม่ใช่ "ค่าที่ user ส่งมา" — มาจาก DI container
  • ถ้า type มันคลุมเครือ (เช่น type ของ service บังเอิญตรงกับ body) ให้ใส่ [FromServices] ชัด ๆ จะปลอดภัยกว่า
  • DI ลึกอยู่บทที่ 7

💡 is { } u ใน svc.Get(id) is { } u = pattern matching (C# 8+) แปลว่า "ถ้าผลลัพธ์ไม่ใช่ null ให้เก็บค่านั้นไว้ในตัวแปร u เพื่อใช้ต่อ" · เทียบเท่ากับ var u = svc.Get(id); return u != null ? Results.Ok(...) : Results.NotFound(); แต่สั้นกว่า · เรียนเต็มในเล่ม csharp/03 + csharp/10

csharp
// Endpoints/UsersEndpoints.cs
public static class UsersEndpoints
{
    public static IEndpointRouteBuilder MapUsers(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/users").WithTags("Users");

        group.MapGet("/", (UsersService svc) =>
            svc.List().Select(ToResponse))   // .Select = LINQ "map" — แปลงแต่ละตัวในลิสต์ให้กลายเป็นอีกแบบ แล้วได้ลิสต์ใหม่ออกมา (เช่น ลิสต์ User ทั้งก้อน แปลงเป็นลิสต์ UserResponse) — เต็มใน csharp/09
            .WithName("ListUsers");

        group.MapGet("/{id:guid}", (Guid id, UsersService svc) =>
            svc.Get(id) is { } u ? Results.Ok(ToResponse(u)) : Results.NotFound())
            .WithName("GetUser");

        group.MapPost("/", (CreateUserDto dto, UsersService svc) =>
        {
            var u = svc.Create(dto);
            return TypedResults.Created($"/users/{u.Id}", ToResponse(u));  // TypedResults = type-safe version — ต่างจาก Results.Created ที่คืนเป็น IResult เฉย ๆ, TypedResults.Created ให้ compiler เช็คได้ว่าค่าที่ return ตรงกับชนิดที่ endpoint ประกาศไว้จริง จับ mismatch ได้ตั้งแต่ compile ไม่ต้องรอรันแล้วพัง
        })
        .WithName("CreateUser");

        group.MapPatch("/{id:guid}", (Guid id, UpdateUserDto dto, UsersService svc) =>
            svc.Update(id, dto) is { } u ? Results.Ok(ToResponse(u)) : Results.NotFound());

        group.MapDelete("/{id:guid}", (Guid id, UsersService svc) =>
            svc.Delete(id) ? Results.NoContent() : Results.NotFound());

        return app;
    }

    private static UserResponse ToResponse(User u) =>
        new(u.Id, u.Name, u.Email, u.CreatedAt);
}

MapGroup = จัดกลุ่ม route ที่แชร์ prefix (คำนำหน้า URL เช่น /users) + middleware ร่วมกัน


5. Program.cs

Program.cs คือจุดที่ประกอบทุกชิ้นเข้าด้วยกัน — ลงทะเบียน service เข้า DI (AddSingleton), ตั้ง OpenAPI/ProblemDetails, จัดลำดับ middleware และเรียก MapUsers() ที่เราเขียนไว้ สังเกตว่า dev (เครื่องนักพัฒนา) กับ prod (เครื่องใช้งานจริง) ตั้ง error handling ต่างกัน:

csharp
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<UsersService>();
builder.Services.AddOpenApi();
builder.Services.AddProblemDetails();   // ใช้รูปแบบ error มาตรฐาน RFC 7807 (เดี๋ยวหัวข้อ 13 อธิบาย)

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    // หมายเหตุ: .NET 6+ เปิด Developer Exception Page อัตโนมัติเมื่อ env=Development
    //         ไม่ต้องเรียก UseDeveloperExceptionPage() เอง
}
else
{
    app.UseExceptionHandler();   // ตอน prod ซ่อนรายละเอียด คืน ProblemDetails แทน (กัน stack trace หลุด)
                                 // ต้องคู่กับ AddProblemDetails() ข้างบน (ลงทะเบียนไว้แล้ว)
}

// ลำดับ middleware ที่สมบูรณ์ (บทต่อ ๆ ไปจะเพิ่มเติมทีละส่วน):
// ExceptionHandler → UseHttpsRedirection → UseCors → UseAuthentication → UseAuthorization → MapXxx
// บรรทัดที่มี * คือจะเพิ่มในบทถัดไป (auth บทที่ 3, CORS §15 บทนี้)
app.UseHttpsRedirection();

app.MapUsers();

app.Run();

6. ทดสอบ

รันแล้วลองยิง request จริงด้วย curl (เครื่องมือยิง HTTP request จาก terminal) เพื่อยืนยันว่าทุก endpoint (create/list/get/update/delete) ทำงานครบ — ทดสอบด้วยมือแบบนี้ก่อนช่วยให้เห็นภาพ API ทำงานจริงก่อนจะเขียน automated test (เทสต์อัตโนมัติ) ในบทถัดไป:

bash
dotnet run

⚠️ port อาจไม่ใช่ 5001 — ดู port จาก output ที่ terminal พิมพ์ตอน dotnet run เช่น Now listening on: https://localhost:7134 แล้วแทน 5001 ด้วยเลขนั้น

bash
# แทน <port> ด้วยเลข port จริงที่เห็นตอน dotnet run (ดูคำเตือนด้านบน)

# create
curl -X POST https://localhost:<port>/users \
  -H "content-type: application/json" \
  -d '{"name":"Alice","email":"a@b.com"}'

# list
curl https://localhost:<port>/users

# get
curl https://localhost:<port>/users/<guid>

# update
curl -X PATCH https://localhost:<port>/users/<guid> \
  -H "content-type: application/json" \
  -d '{"name":"Bob"}'

# delete
curl -X DELETE https://localhost:<port>/users/<guid> -i

7. Validation — Manual + Auto

Minimal API ไม่ทำ validation (การตรวจความถูกต้องของข้อมูลที่รับเข้ามา) อัตโนมัติ — ต้องทำเองหรือใช้ endpoint filter (ดูหัวข้อ 8) ส่วน Controller แบบ [ApiController] ที่จะเห็นใน §9 ทำให้อัตโนมัติ มี 2 ทางหลักสำหรับ Minimal API:

  1. Validate manual ใน handler ด้วย Validator.TryValidateObject(...) ที่ built-in มากับ .NET (ตัวอย่างด้านล่าง)
  2. Endpoint Filter ทำ validation ครอบ endpoint (ดู §8) — แนะนำให้ใช้ FluentValidation (§12)

สำหรับ production แนะนำข้าม manual validation ไปใช้ FluentValidation โดยตรง เพราะ rule ที่ซับซ้อนจะอ่านง่ายกว่ามาก

manual validation:

csharp
// ต้องมี using นี้ที่บนไฟล์ (หรือใน Program.cs) เพื่อใช้ ValidationContext/ValidationResult/Validator
using System.ComponentModel.DataAnnotations;

group.MapPost("/", (CreateUserDto dto, UsersService svc) =>
{
    var ctx = new ValidationContext(dto);
    var errors = new List<ValidationResult>();
    if (!Validator.TryValidateObject(dto, ctx, errors, true))
        return (IResult)Results.ValidationProblem(  // cast เป็น IResult เพื่อให้ทั้งสอง branch มี return type เดียวกัน
            errors.ToDictionary(
                e => e.MemberNames.FirstOrDefault() ?? "",  // ป้องกัน InvalidOperationException ถ้า MemberNames ว่างเปล่า
                e => new[] { e.ErrorMessage! }));
    var u = svc.Create(dto);
    return (IResult)TypedResults.Created($"/users/{u.Id}", ToResponse(u));
});

8. Endpoint Filter (.NET 7+)

endpoint filter คือ middleware ระดับ endpoint ใน Minimal API ที่รันก่อน/หลัง handler

เหมาะกับ cross-cutting concern — เรื่องที่กระจายอยู่ทั่วทุก endpoint เช่น logging, ตรวจสิทธิ์, validation

เขียนเป็น reusable filter (filter ที่ใช้ซ้ำได้ผ่าน interface IEndpointFilter) แล้วแปะกับหลาย endpoint ได้ → handler โฟกัสแค่ business logic:

csharp
group.MapPost("/", ...).AddEndpointFilter(async (ctx, next) =>
{
    var dto = ctx.GetArgument<CreateUserDto>(0);
    var validationResults = new List<ValidationResult>();
    if (!Validator.TryValidateObject(dto, new(dto), validationResults, true))
        return Results.ValidationProblem(/*...*/);
    return await next(ctx);
});

หรือ extract เป็น reusable filter:

💡 อ่านโค้ดด้านล่างทีละบรรทัด:

  • IEndpointFilter = interface ที่ ASP.NET Core กำหนดไว้ให้ implement — มี method เดียวคือ InvokeAsync
  • EndpointFilterInvocationContext ctx = ข้อมูลของ request ที่กำลังเข้ามา (รวม argument ทุกตัวที่ handler จะได้รับ)
  • EndpointFilterDelegate next = "handler ตัวถัดไปในสาย" — เรียก next(ctx) เพื่อส่งต่อไปยัง filter ถัดไป หรือ handler จริงถ้าไม่มี filter อื่นแล้ว (ถ้าไม่เรียก next request จะหยุดอยู่ตรงนี้เลย)
  • ctx.Arguments.OfType<T>().FirstOrDefault() = ค้นหา argument ตัวแรกที่เป็นชนิด T (เช่น CreateUserDto) จาก argument ทั้งหมดของ endpoint
csharp
public class ValidationFilter<T> : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
    {
        // ⚠️ assume DTO อยู่ที่ argument index 0 — ถ้า endpoint signature เปลี่ยน
        //    (เช่น เพิ่ม service parameter มาก่อน DTO) จะ break เงียบ ๆ
        //    ทางที่ทนกว่าคือ loop หา argument ตัวแรกที่เป็น type T
        var arg = ctx.Arguments.OfType<T>().FirstOrDefault();
        if (arg is null) return await next(ctx);
        // validate...
        return await next(ctx);
    }
}

// ใช้
group.MapPost("/", ...).AddEndpointFilter<ValidationFilter<CreateUserDto>>();

9. Controller Version (เทียบ)

นอกจาก Minimal API แล้ว ASP.NET Core ยังมีแบบ Controller ดั้งเดิม — Controller คือ class ที่รวมหลาย endpoint ไว้ด้วยกัน เขียนแต่ละ endpoint เป็น method ที่มี attribute ([HttpGet], [HttpPost] ฯลฯ) กำกับ

เหมาะกับ API ใหญ่ที่มี endpoint เยอะและอยากจัดเป็นกลุ่ม · [ApiController] ให้ validation + problem details อัตโนมัติ

มาดูเทียบกับ Minimal API ที่เขียนไปแล้ว:

csharp
// Controllers/UsersController.cs
[ApiController]
[Route("[controller]")]
public class UsersController : ControllerBase
{
    private readonly UsersService _svc;
    public UsersController(UsersService svc) => _svc = svc;

    [HttpGet]
    public IActionResult List() => Ok(_svc.List().Select(ToResp));

    [HttpGet("{id:guid}")]
    public IActionResult Get(Guid id) =>
        _svc.Get(id) is { } u ? Ok(ToResp(u)) : NotFound();

    [HttpPost]
    public IActionResult Create([FromBody] CreateUserDto dto)
    {
        var u = _svc.Create(dto);
        return CreatedAtAction(nameof(Get), new { id = u.Id }, ToResp(u));
    }

    [HttpPatch("{id:guid}")]
    public IActionResult Update(Guid id, [FromBody] UpdateUserDto dto) =>
        _svc.Update(id, dto) is { } u ? Ok(ToResp(u)) : NotFound();

    [HttpDelete("{id:guid}")]
    public IActionResult Delete(Guid id) =>
        _svc.Delete(id) ? NoContent() : NotFound();

    private static UserResponse ToResp(User u) =>
        new(u.Id, u.Name, u.Email, u.CreatedAt);
}

[ApiController] = อัตโนมัติ:

  • 400 ถ้า validation fail ([Required], [EmailAddress])
  • [FromBody]/[FromQuery] infer ตาม type
  • problem details format
csharp
// Program.cs (เวอร์ชัน controller)
builder.Services.AddControllers();
builder.Services.AddSingleton<UsersService>();   // ⚠️ ต้อง register service เหมือนใน Minimal API
app.MapControllers();

💡 อย่าลืม AddSingleton<UsersService>() — ถ้าลืมจะ throw InvalidOperationException ตอน DI พยายาม resolve service ของ controller


10. Route Constraints

route constraint (เงื่อนไขจำกัดรูปแบบของ route) จำกัดว่า path parameter (ค่าที่ฝังใน URL เช่น id) ต้องเป็นรูปแบบไหน ({id:guid}, {id:int:min(1)}) — ถ้าไม่ตรงก็ไม่ match route นั้น ช่วยกรอง input ผิดรูปตั้งแต่ชั้น routing ก่อนเข้า handler และทำให้ URL หลายแบบ map ไป handler ที่ถูก:

csharp
app.MapGet("/users/{id:guid}", ...);      // id ต้องเป็น Guid
app.MapGet("/items/{id:int}", ...);       // int
app.MapGet("/items/{id:int:min(1)}", ...); // > 0
app.MapGet("/items/{name:alpha}", ...);   // a-z only
app.MapGet("/items/{slug:regex(^[a-z0-9-]+$)}", ...);

11. Model Binding Sources

ASP.NET Core ต้องรู้ว่าจะดึงค่าของแต่ละ parameter จากไหน — query string (ค่าหลัง ? ใน URL), body (เนื้อหาที่ส่งมาใน request), header (ส่วนหัวของ request) หรือ DI โดย Minimal API เดาให้อัตโนมัติ (simple type เช่น int/string → query, complex type → body) แต่ระบุชัดด้วย [FromQuery]/[FromHeader]/[FromServices] ได้เมื่อต้องการความชัดเจน:

csharp
// Minimal API auto-infer:
// - simple type (int, string) → query
// - complex type → body
// - IFormFile → form

// ระบุ explicit
app.MapGet("/search", (
    [FromQuery] string q,
    [FromQuery(Name = "page")] int page = 1,
    [FromHeader(Name = "X-Tenant")] string tenant,
    [FromServices] UsersService svc,
    HttpContext ctx
) => /* ... */);

// Route parameter
app.MapGet("/users/{id}", ([FromRoute] Guid id) => /*...*/);

Data Annotation (การกำกับ rule ด้วย attribute เช่น [Required]) ดีสำหรับ rule ง่าย ๆ แต่พอ validation ซับซ้อนโค้ดจะรกและอ่านยาก เพราะ attribute หลายตัวกองอยู่บน DTO เช่น:

  • cross-field = ตรวจหลาย field เทียบกัน (เช่น EndDate > StartDate)
  • async check DB = ตรวจกับ database แบบ async (เช่น email ซ้ำหรือไม่)

FluentValidation แยก rule ออกเป็น class ต่างหาก เขียนแบบ fluent (ต่อ method เป็นประโยคอ่านลื่น) → DTO สะอาดและ test ได้

ส่วนนี้แสดงวิธีตั้งและใช้กับ endpoint filter:

bash
dotnet add package FluentValidation.AspNetCore
csharp
// : AbstractValidator<T> คือการ "สืบทอด" (inherit) จาก class ของ FluentValidation
// ทำให้เราได้ RuleFor method มาใช้โดยอัตโนมัติ — เรียนเรื่อง inheritance เต็มในเล่ม csharp/บท OOP
public class CreateUserValidator : AbstractValidator<CreateUserDto>
{
    public CreateUserValidator()
    {
        RuleFor(x => x.Name).NotEmpty().MinimumLength(2);
        RuleFor(x => x.Email).NotEmpty().EmailAddress();
    }
}

// register
builder.Services.AddValidatorsFromAssemblyContaining<CreateUserValidator>();

// filter
group.MapPost("/", ...).AddEndpointFilter<ValidationFilter<CreateUserDto>>();

ดี FluentValidation:

  • complex rule (cross-field)
  • async validation (check DB unique)
  • separate from DTO (DTO ยัง clean)

13. Problem Details (RFC 7807)

แทนที่จะคืน error format มั่ว ๆ ที่ client แต่ละตัวต้องเดาเอง มีมาตรฐานกลางชื่อ RFC 7807 (Problem Details)

RFC 7807 กำหนด field มาตรฐานของ error response: type / title / status / detail — ทำให้ client parse ได้สม่ำเสมอ ไม่ต้องเขียน parser แยกตาม API

ASP.NET Core รองรับในตัวผ่าน Results.Problem():

csharp
app.MapGet("/teapot", () =>
    Results.Problem(
        title: "I am a teapot",
        statusCode: StatusCodes.Status418ImATeapot,
        detail: "Cannot brew coffee."));

Response:

json
{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "I am a teapot",
  "status": 418,
  "detail": "Cannot brew coffee."
}

💡 รายละเอียดเชิงลึกอ่านได้จาก RFC 7807 โดยตรง


14. Versioning

เมื่อ API มี client ใช้งานจริงแล้ว การเปลี่ยน contract แบบ breaking (เปลี่ยนจนของเดิมพัง) จะทำ client พัง — API versioning (การแบ่งเวอร์ชันของ API) ให้คุณปล่อย v2 โดยที่ v1 ยังทำงานอยู่ package Asp.Versioning ช่วยจัดการ version ผ่าน URL/header ส่วนนี้แสดงการตั้งค่า:

bash
dotnet add package Asp.Versioning.Http     # ใช้ v6+ ขึ้นไป (มี AddApiVersioning API ครบ)

💡 new(1, 0) = ย่อมาจาก new ApiVersion(1, 0) — compiler เดาชนิดให้เองจาก context ที่ประกาศไว้แล้ว (เช่นชนิดของ property/parameter ฝั่งซ้าย) เรียกว่า target-typed new expression (C# 9+) ไม่ต้องพิมพ์ชื่อ type ซ้ำ · ฟีเจอร์นี้ต้องการ C# 9+ ไม่ใช่เวอร์ชัน package — ตรวจ <LangVersion> ใน .csproj หรือใช้ new ApiVersion(1, 0) แบบเต็มถ้า compiler บ่น

csharp
builder.Services.AddApiVersioning(o =>
{
    o.DefaultApiVersion = new(1, 0);                    // ต้องใช้ Asp.Versioning v6+
    o.ReportApiVersions = true;
    o.ApiVersionReader = new UrlSegmentApiVersionReader();
});

var versionSet = app.NewApiVersionSet()
    .HasApiVersion(new(1, 0))
    .HasApiVersion(new(2, 0))
    .Build();

app.MapGet("/v{version:apiVersion}/users", () => /*...*/).WithApiVersionSet(versionSet);

15. CORS

เรื่อง origin + same-origin policy:

  • origin = โดเมน + พอร์ต + โปรโตคอล (เช่น https://app.example.com:443) — ต่างชิ้นใดชิ้นหนึ่งถือเป็นคนละ origin
  • same-origin policy = กฎของเบราว์เซอร์ที่ยอมให้ JS เรียก API ได้เฉพาะ origin เดียวกันกับหน้าเว็บ
  • ผลคือ frontend (เว็บหน้าบ้าน) ที่ host คนละ origin จะเรียก API ของเราไม่ได้

CORS (Cross-Origin Resource Sharing) = กลไกที่ฝั่ง server "อนุญาต" origin ที่ไว้ใจให้เรียกข้าม origin ได้

csharp
// ตัวอย่างนี้ใช้ AllowAnyHeader/AllowAnyMethod เพื่อความสั้น
// สำหรับ production ควรระบุเฉพาะที่จำเป็น เช่น:
// .WithHeaders("content-type", "authorization")
// .WithMethods("GET", "POST", "PATCH", "DELETE")
builder.Services.AddCors(o =>
    o.AddPolicy("Frontend", p => p
        .WithOrigins("https://app.example.com")
        .AllowAnyHeader()
        .AllowAnyMethod()
        .AllowCredentials()));

app.UseCors("Frontend");

⚠️ จุดที่พลาดบ่อย — ลำดับ middleware: ต้องเรียก UseCors ก่อน UseAuthorization และ MapXxx ทุกตัว · ถ้าเรียงผิด preflight request (ดู §17) จะได้ 405

🚨 ข้อห้ามด้านความปลอดภัย — AllowAnyOrigin() + AllowCredentials() ห้ามใช้ร่วมกันเด็ดขาด!

  • CORS spec ระบุชัดว่า origin = * ใช้กับ credentials (cookie/Authorization header) ไม่ได้
  • ASP.NET Core จะ block ตอน runtime แต่อย่ารอให้ runtime จับ — ต้องระบุ origin ที่ไว้ใจอย่างน้อย 1 ตัวเสมอ
  • CORS = auth boundary (ขอบเขตด้านความปลอดภัย) ไม่ใช่แค่ "ทำให้เรียกได้" — ตั้งหลวมเกินไป = เปิดทางให้เว็บมุ่งร้ายเรียก API ของเราด้วย cookie ของผู้ใช้

16. Rate Limit (.NET 7+)

ตั้งแต่ .NET 7 เป็นต้นมา rate limiting (การจำกัดจำนวน request ต่อช่วงเวลา) มาในตัว framework — ไม่ต้องพึ่ง library ภายนอก

ใช้กัน abuse (การใช้งานในทางที่ผิด เช่น ยิงรัวเพื่อล้มระบบ) และ brute-force (การลองเดารหัสซ้ำ ๆ จำนวนมาก)

apply เฉพาะ endpoint ที่ต้องการด้วย .RequireRateLimiting("ชื่อ policy"):

csharp
builder.Services.AddRateLimiter(o =>
{
    o.AddFixedWindowLimiter("api", opt =>
    {
        opt.PermitLimit = 100;
        opt.Window = TimeSpan.FromMinutes(1);
    });
});

app.UseRateLimiter();
group.MapPost("/login", ...).RequireRateLimiting("api");

17. Common Pitfalls

  1. Route conflict (route ชนกัน)/users/me ตัดกับ /users/{id} → ใช้ constraint หรือ map static ก่อน
  2. ลืม [ApiController] ใน controller → ไม่ได้ auto 400 (ตอบ 400 อัตโนมัติเมื่อ validation fail)
  3. HttpContext.Response.WriteAsync(...) แล้ว return result → กลายเป็น 2 response
  4. CORS หลัง routing → preflight (request ทดสอบสิทธิ์ที่ browser ส่งก่อน) ได้ 405
  5. Minimal API + DI ที่เป็น Scoped — ทุก endpoint อยู่ใน request scope ปกติ ใช้ได้ปกติ
  6. JSON cycle — object อ้างวนกันไม่จบเวลา serialize เช่น User มี property Posts แต่ละ Post มี Author ที่ชี้กลับมา User คนเดิม → JSON serializer วน loop ไม่จบ · แก้ด้วยการใช้ DTO ที่ไม่มี navigation วน หรือใส่ option ReferenceHandler.IgnoreCycles

🛠️ Checkpoint 1 — ลงมือทำ

  1. ทำ Minimal API CRUD ตามบทนี้ + เปิด OpenAPI document ดูที่ /openapi/v1.json (หรือ Swagger UI ถ้า install Scalar/Swashbuckle เพิ่ม)
  2. ลอง POST validation invalid → ดู response format (Problem Details)
  3. เพิ่ม endpoint GET /users/search?q=... filter ตามชื่อ
  4. เพิ่ม versioning v1 vs v2 ที่ shape ต่างกัน
  5. ลองเขียนเวอร์ชัน Controller แบบเดียวกัน → เทียบความยาว/feature

สรุปบทที่ 1

  • Minimal API = สั้นและเร็ว, MapGroup รวม route
  • DTO + record = สะอาด + immutable (สร้างแล้วแก้ค่าไม่ได้ — ปลอดภัยต่อ bug)
  • DI ผ่าน parameter (Minimal) หรือ constructor (Controller)
  • Endpoint Filter ทำ cross-cutting (validation, auth)
  • FluentValidation > DataAnnotation สำหรับ rule ซับซ้อน
  • Problem Details = error format มาตรฐาน
  • CORS, Rate Limit, Versioning = มีในตัว framework ทุกตัว

→ บทที่ 2: Database + EF Core