โหมดมืด
บทที่ 1 — REST API แรก (Minimal + Controller)
บทนี้สร้าง 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 (มี methodMainชัดเจน) แทน 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 endpoint2. Model + DTO
เริ่มจากนิยามข้อมูล แยกเป็น 2 ฝั่ง:
- Model คือ class ที่เป็นตัวแทนข้อมูลในแอป เช่น
Userคือข้อมูลของผู้ใช้คนหนึ่ง (บทที่ 2 จะสอนเชื่อมกับ database) - DTO (Data Transfer Object — วัตถุสำหรับรับ-ส่งข้อมูล) คือรูปแบบที่ใช้รับ-ส่งกับ client โดยแยกออกเป็น create/update/response
ทำไมต้องแยก model กับ DTO?
- กัน over-posting = การที่ client แอบส่ง field เกินมาแก้ข้อมูลที่ไม่ควรแก้ — เช่น ถ้าไม่แยก DTO client อาจส่ง
{"name":"Alice","isAdmin":true}มา แล้ว user จะกลายเป็น admin ทันที - ให้ 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/05record(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ไม่ใช่Dictionary—UsersServiceregister เป็น Singleton ใน DI (ดู §5) → ทุก request แชร์ instance เดียวกัน ·Dictionary<K,V>ธรรมดา ไม่ thread-safe — ถ้าสอง request เขียน/อ่านพร้อมกัน อาจ corrupt state หรือ throwInvalidOperationExceptionตอน 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> -i7. Validation — Manual + Auto
Minimal API ไม่ทำ validation (การตรวจความถูกต้องของข้อมูลที่รับเข้ามา) อัตโนมัติ — ต้องทำเองหรือใช้ endpoint filter (ดูหัวข้อ 8) ส่วน Controller แบบ [ApiController] ที่จะเห็นใน §9 ทำให้อัตโนมัติ มี 2 ทางหลักสำหรับ Minimal API:
- Validate manual ใน handler ด้วย
Validator.TryValidateObject(...)ที่ built-in มากับ .NET (ตัวอย่างด้านล่าง) - 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 เดียวคือInvokeAsyncEndpointFilterInvocationContext ctx= ข้อมูลของ request ที่กำลังเข้ามา (รวม argument ทุกตัวที่ handler จะได้รับ)EndpointFilterDelegate next= "handler ตัวถัดไปในสาย" — เรียกnext(ctx)เพื่อส่งต่อไปยัง filter ถัดไป หรือ handler จริงถ้าไม่มี filter อื่นแล้ว (ถ้าไม่เรียกnextrequest จะหยุดอยู่ตรงนี้เลย)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>()— ถ้าลืมจะ throwInvalidOperationExceptionตอน 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) => /*...*/);12. FluentValidation (recommended)
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.AspNetCorecsharp
// : 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
- Route conflict (route ชนกัน) —
/users/meตัดกับ/users/{id}→ ใช้ constraint หรือ map static ก่อน - ลืม
[ApiController]ใน controller → ไม่ได้ auto 400 (ตอบ 400 อัตโนมัติเมื่อ validation fail) HttpContext.Response.WriteAsync(...)แล้ว return result → กลายเป็น 2 response- CORS หลัง routing → preflight (request ทดสอบสิทธิ์ที่ browser ส่งก่อน) ได้ 405
- Minimal API + DI ที่เป็น Scoped — ทุก endpoint อยู่ใน request scope ปกติ ใช้ได้ปกติ
- JSON cycle — object อ้างวนกันไม่จบเวลา serialize เช่น
Userมี propertyPostsแต่ละPostมีAuthorที่ชี้กลับมา User คนเดิม → JSON serializer วน loop ไม่จบ · แก้ด้วยการใช้ DTO ที่ไม่มี navigation วน หรือใส่ optionReferenceHandler.IgnoreCycles
🛠️ Checkpoint 1 — ลงมือทำ
- ทำ Minimal API CRUD ตามบทนี้ + เปิด OpenAPI document ดูที่
/openapi/v1.json(หรือ Swagger UI ถ้า install Scalar/Swashbuckle เพิ่ม) - ลอง POST validation invalid → ดู response format (Problem Details)
- เพิ่ม endpoint
GET /users/search?q=...filter ตามชื่อ - เพิ่ม versioning v1 vs v2 ที่ shape ต่างกัน
- ลองเขียนเวอร์ชัน 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 ทุกตัว