Model Binding dan Validasi

DataAnnotations dan respons 400.

Daftar isi

Model binding adalah mekanisme yang mengubah data mentah dari request HTTP menjadi parameter bertipe kuat pada endpoint. Nilai dari rute, query string, dan body JSON dipetakan secara otomatis ke parameter metode, sedangkan DataAnnotations memeriksa kepatuhan nilai tersebut terhadap aturan yang dideklarasikan. Topik ini membahas sumber data yang dibaca model binding, penulisan model dengan atribut [Required] dan [Range], serta pembentukan response 400 bertipe ValidationProblem, dan ditutup dengan demo pemesanan tiket yang keluarannya dicetak dari eksekusi nyata.

Sumber data yang dibaca model binding#

Setiap parameter pada endpoint API minimal mendapatkan sumber datanya dari tipe dan bentuk deklarasinya. Parameter bertipe sederhana seperti string dan int diikat dari nilai rute terlebih dahulu, kemudian dari query string apabila nama yang sama tidak terdapat pada rute. Parameter bertipe kelas atau record diperlakukan sebagai tipe kompleks dan diikat dari body request yang diurai sebagai JSON. Mekanisme ini dijalankan oleh sekumpulan binder provider, yaitu komponen yang mengenali sumber data dan melakukan konversinya ke tipe parameter.

Konversi tipe dilakukan secara otomatis selama nilai sumbernya dapat diubah. Teks 3 pada query string menjadi int bernilai tiga tanpa kode tambahan, sedangkan teks dua gagal dikonversi. Kegagalan konversi maupun kegagalan penguraian JSON menghasilkan response 400 sebelum kode endpoint dijalankan, sehingga endpoint tidak pernah menerima nilai yang tidak dapat diikat. Tabel berikut merangkum sumber data yang paling sering digunakan.

SumberContohDiikat ke
Nilai rute/tiket/Jogjaparameter tujuan pada rute /tiket/{tujuan}
Query string?jumlah=3parameter atau property bernama jumlah
Body JSON{"tujuan":"Jogja","jumlah":3}parameter bertipe kelas atau record

Urutan sumber ini membuat satu endpoint dapat menerima data dari beberapa tempat sekaligus, misalnya tujuan dari rute dan jumlah dari query string. Agar keduanya berkumpul dalam satu objek, atribut [AsParameters] memerintahkan model binding mengisi property sebuah record dari sumber-sumber tersebut, sehingga endpoint menerima satu parameter dan bukan daftar parameter yang terpisah. Cara ini menjaga tanda tangan endpoint tetap pendek pada endpoint yang membawa banyak data.

Model dan DataAnnotations#

DataAnnotations adalah kumpulan atribut dari namespace System.ComponentModel.DataAnnotations yang menyatakan aturan validasi secara deklaratif di atas property. Atribut [Required] menolak nilai kosong, sedangkan [Range] membatasi nilai angka pada rentang tertentu. Setiap atribut menerima ErrorMessage sehingga pesan kegagalan ditulis dalam bahasa yang dipilih aplikasi dan bukan pesan bawaan berbahasa Inggris.

API minimal tidak menjalankan pemeriksaan DataAnnotations secara otomatis, sehingga endpoint memanggil pemeriksaan itu secara eksplisit melalui kelas Validator. Metode Validator.TryValidateObject mengembalikan true apabila semua aturan terpenuhi, sedangkan kegagalan menghasilkan daftar ValidationResult yang membawa nama property pada MemberNames dan pesannya pada ErrorMessage. Daftar tersebut kemudian dikemas dengan Results.ValidationProblem menjadi response 400 berformat ProblemDetails, yaitu format standar untuk deskripsi error HTTP dengan properti status, title, dan errors. Kunci pada errors adalah nama property yang gagal, sehingga klien dapat menunjuk kembali kesalahannya ke bidang data yang bersangkutan.

csharp Contoh: model pemesanan dengan DataAnnotations
// tidak-dijalankan
class Pemesanan
{
    [Required(ErrorMessage = "Tujuan wajib diisi")]
    public string Tujuan { get; set; } = string.Empty;

    [Range(1, 10, ErrorMessage = "Jumlah harus antara 1 sampai 10")]
    public int Jumlah { get; set; }
}

Model di atas menuntut Tujuan terisi dan Jumlah bernilai antara satu sampai sepuluh. Kedua aturan diperiksa pada saat endpoint memanggil TryValidateObject, bukan pada saat property dibaca, sehingga objek yang tidak valid tetap terbentuk sebagai objek C# yang utuh sebelum ditolak. Pendekatan eksplisit ini membuat titik pemeriksaan terlihat jelas di dalam kode endpoint.

Demo binding dan validasi pada endpoint tiket#

Program berikut menggabungkan seluruh materi topik dalam satu aplikasi. Endpoint GET /tiket/{tujuan} mengikat nilai rute dan query string ke record KriteriaTiket melalui [AsParameters], sedangkan endpoint POST /tiket mengikat body JSON ke kelas Pemesanan lalu memeriksanya dengan Validator. Aplikasi berkas tunggal ASP.NET Core memerlukan dua arahan tambahan di baris atas berkas: #:property JsonSerializerIsReflectionEnabledByDefault=true mengaktifkan kembali serializer JSON berbasis refleksi yang dimatikan secara default pada aplikasi berkas tunggal, sedangkan #:property NoWarn=IL2026 menekan peringatan analisis trimming yang muncul karena Validator menggunakan refleksi.

Server dimulai dengan StartAsync, diuji dengan empat request lewat HttpClient dari dalam proses yang sama, lalu dihentikan dengan StopAsync, sehingga keluarannya selalu sama pada setiap eksekusi. Aktifkan .NET pada sesi terminal dengan perintah berikut sebelum mencoba programnya.

bash
# aktifkan .NET yang terpasang di sandbox
export DOTNET_ROOT=/home/kal/.dotnet PATH=/home/kal/.dotnet:$PATH DOTNET_CLI_TELEMETRY_OPTOUT=1 DOTNET_NOLOGO=1

Simpan program berikut sebagai Program.cs di folder topik, lalu jalankan dengan perintah dotnet run --file Program.cs.

csharp Program.cs
#:sdk Microsoft.NET.Sdk.Web
#:property JsonSerializerIsReflectionEnabledByDefault=true
#:property NoWarn=IL2026

using System.ComponentModel.DataAnnotations;
using System.Text;
using System.Text.Json;

var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();

var app = builder.Build();
app.Urls.Add("http://127.0.0.1:5020");

app.MapGet("/tiket/{tujuan}", ([AsParameters] KriteriaTiket kriteria) =>
    Results.Ok(new { kriteria.Tujuan, kriteria.Jumlah }));

app.MapPost("/tiket", (Pemesanan pesanan) =>
{
    var konteks = new ValidationContext(pesanan);
    var temuan = new List<ValidationResult>();
    if (!Validator.TryValidateObject(pesanan, konteks, temuan, validateAllProperties: true))
    {
        var error = temuan
            .SelectMany(t => t.MemberNames.DefaultIfEmpty(string.Empty),
                (t, nama) => (Nama: nama, Pesan: t.ErrorMessage!))
            .GroupBy(x => x.Nama)
            .ToDictionary(g => g.Key, g => g.Select(x => x.Pesan).ToArray());
        return Results.ValidationProblem(error);
    }
    return Results.Ok(new { pesanan.Tujuan, pesanan.Jumlah, Status = "diterima" });
});

await app.StartAsync();
using var client = new HttpClient();
client.BaseAddress = new Uri("http://127.0.0.1:5020");

var jawab1 = await client.GetAsync("/tiket/Jogja?jumlah=3");
Console.WriteLine($"GET  /tiket/Jogja?jumlah=3  -> {(int)jawab1.StatusCode} {await jawab1.Content.ReadAsStringAsync()}");

var jawab2 = await client.GetAsync("/tiket/Jogja?jumlah=dua");
var isi2 = await jawab2.Content.ReadAsStringAsync();
Console.WriteLine($"GET  /tiket/Jogja?jumlah=dua -> {(int)jawab2.StatusCode} isi {isi2.Length} karakter");

var jawab3 = await client.PostAsync("/tiket",
    new StringContent("""{"tujuan":"Semarang","jumlah":2}""", Encoding.UTF8, "application/json"));
Console.WriteLine($"POST /tiket (valid)         -> {(int)jawab3.StatusCode} {await jawab3.Content.ReadAsStringAsync()}");

var jawab4 = await client.PostAsync("/tiket",
    new StringContent("""{"tujuan":"","jumlah":15}""", Encoding.UTF8, "application/json"));
Console.WriteLine($"POST /tiket (tidak valid)   -> {(int)jawab4.StatusCode}");
using (var dok = JsonDocument.Parse(await jawab4.Content.ReadAsStringAsync()))
{
    foreach (var pasangan in dok.RootElement.GetProperty("errors").EnumerateObject())
    {
        Console.WriteLine($"  {pasangan.Name}: {pasangan.Value.EnumerateArray().First().GetString()}");
    }
}

await app.StopAsync();

record KriteriaTiket(string Tujuan, int Jumlah);

class Pemesanan
{
    [Required(ErrorMessage = "Tujuan wajib diisi")]
    public string Tujuan { get; set; } = string.Empty;

    [Range(1, 10, ErrorMessage = "Jumlah harus antara 1 sampai 10")]
    public int Jumlah { get; set; }
}
text Keluaran
GET  /tiket/Jogja?jumlah=3  -> 200 {"tujuan":"Jogja","jumlah":3}
GET  /tiket/Jogja?jumlah=dua -> 400 isi 0 karakter
POST /tiket (valid)         -> 200 {"tujuan":"Semarang","jumlah":2,"status":"diterima"}
POST /tiket (tidak valid)   -> 400
  Tujuan: Tujuan wajib diisi
  Jumlah: Jumlah harus antara 1 sampai 10

Keluaran tersebut memperlihatkan empat jalur yang berbeda. Request pertama membawa tujuan dari rute dan jumlah=3 dari query string, dan keduanya terikat ke satu record sehingga isi response menyusun ulang keduanya sebagai JSON. Request kedua gagal pada konversi jumlah=dua ke int, sehingga response 400 dikirim dengan body kosong dan kode endpoint tidak pernah dijalankan. Request ketiga lolos validasi sehingga statusnya diterima. Request keempat melanggar dua aturan sekaligus, dan bagian errors hasil penguraian body ValidationProblem menampilkan kunci Tujuan dan Jumlah dengan pesan yang dideklarasikan pada atributnya. Body lengkap ValidationProblem juga memuat properti title dan status, sedangkan demo hanya menampilkan bagian errors agar keluarannya tetap ringkas.

Kesalahan umum#

  • Lupa menambahkan #:property JsonSerializerIsReflectionEnabledByDefault=true pada aplikasi berkas tunggal. Binding body JSON gagal dengan response 500 tanpa isi karena serializer refleksi dimatikan secara default, dan perbaikannya adalah menambahkan arahan tersebut di baris atas berkas.
  • Menganggap atribut DataAnnotations diperiksa secara otomatis pada API minimal. Atribut hanya menyatakan aturannya, sehingga endpoint yang tidak memanggil Validator.TryValidateObject menerima data tidak valid tanpa peringatan apa pun.
  • Menyangka kode endpoint tetap dijalankan ketika binding gagal. Kegagalan konversi tipe menghasilkan response 400 sebelum kode endpoint dieksekusi, sehingga logika di dalam endpoint tidak pernah melihat nilai yang salah tersebut dan penanganannya lebih tepat dilakukan pada sisi pengirim data.

Ringkasan#

  • Model binding mengubah nilai rute, query string, dan body JSON menjadi parameter bertipe kuat, dengan parameter sederhana diikat dari rute lalu query string dan tipe kompleks diikat dari body.
  • Atribut [AsParameters] mengikat beberapa sumber sekaligus ke property sebuah record, sehingga data rute dan query string berkumpul dalam satu objek.
  • DataAnnotations menyatakan aturan validasi secara deklaratif, dan Validator.TryValidateObject mengubah kegagalannya menjadi daftar ValidationResult yang dikirim sebagai response 400 melalui Results.ValidationProblem.
  • Kegagalan konversi tipe pada saat binding menghasilkan response 400 dengan body kosong sebelum kode endpoint dijalankan.

Referensi#