İçeriğe geç / Skip to content / Zum Inhalt
Ahmet Balaman LogoAhmet Balaman

ASP.NET Core Minimal API ile İlk REST API: Başlangıç Rehberi

Ahmet Balaman

6 dk okuma

.NETASP.NET CoreMinimal APIREST APIC#Web API
ASP.NET Core Minimal API ile İlk REST API: Başlangıç Rehberi

Minimal API, ASP.NET Core'da controller sınıfı, attribute ve klasör yapısı kurmadan, tek bir Program.cs dosyasında HTTP endpoint'leri tanımlamanın yoludur. Mobil uygulamanıza ya da bir frontend'e veri verecek küçük bir servis yazarken, öğrenirken veya bir fikri hızlıca denerken ilk uzanacağınız araçtır. Bu yazıda boş projeden başlayıp kitapları listeleyen, ekleyen, güncelleyen ve silen bir REST API yazıyoruz; yol boyunca route parametrelerini, model binding'i, TypedResults'ı, dependency injection'ı ve doğrulamayı görüyoruz.

Projeyi Oluşturmak

.NET SDK kuruluysa terminalde iki komut yeter:

dotnet new web -o KitapApi
cd KitapApi
dotnet run

web şablonu en sade ASP.NET Core projesidir. Oluşan Program.cs birkaç satırdan ibarettir:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.Run();

builder kısmı servislerin ve ayarların hazırlandığı yer, app kısmı ise isteklerin karşılandığı yerdir. dotnet run çıktısındaki Now listening on: http://localhost:... satırı uygulamanın hangi portta çalıştığını gösterir. Port her projede farklı üretilir; aşağıdaki örneklerde 5000 yazdığım yere kendi portunuzu koyun.

Veri Modeli ve Bellek İçi Servis

Class ve object yazısındaki kütüphane örneğine geri dönelim ve kitapları bir API'ye taşıyalım. Önce modeller. Program.cs içinde tip tanımları, üst seviye kodun altında durmak zorundadır; dosyanın sonuna ekleyin:

public record Kitap(int Id, string Baslik, string Yazar, int Yil);

public record KitapIstegi(string? Baslik, string? Yazar, int Yil);

İki ayrı tip kullanmamızın nedeni şu: Id değerini istemci değil sunucu belirler. İstemciden gelen veri KitapIstegi, dışarıya dönen veri Kitap'tır.

Veritabanı bu yazının konusu değil; veriyi bellekte tutan basit bir servis yazıyoruz. Servisi bir interface arkasına koymak, ileride gerçek veritabanına geçerken endpoint'lere dokunmamayı sağlar. Bu ayrımın mantığını interface ve abstract class farkı yazısında anlattım.

public interface IKitapDeposu
{
    IReadOnlyList<Kitap> Listele(string? yazar);
    Kitap? Bul(int id);
    Kitap Ekle(KitapIstegi istek);
    bool Guncelle(int id, KitapIstegi istek);
    bool Sil(int id);
}

public class BellekKitapDeposu : IKitapDeposu
{
    private readonly ConcurrentDictionary<int, Kitap> _kitaplar = new();
    private int _sonId;

    public IReadOnlyList<Kitap> Listele(string? yazar) =>
        _kitaplar.Values
            .Where(k => yazar is null ||
                        k.Yazar.Contains(yazar, StringComparison.OrdinalIgnoreCase))
            .OrderBy(k => k.Id)
            .ToList();

    public Kitap? Bul(int id) => _kitaplar.GetValueOrDefault(id);

    public Kitap Ekle(KitapIstegi istek)
    {
        int id = Interlocked.Increment(ref _sonId);
        var kitap = new Kitap(id, istek.Baslik!.Trim(), istek.Yazar!.Trim(), istek.Yil);
        _kitaplar[id] = kitap;
        return kitap;
    }

    public bool Guncelle(int id, KitapIstegi istek)
    {
        if (!_kitaplar.TryGetValue(id, out var eski))
            return false;

        var yeni = eski with
        {
            Baslik = istek.Baslik!.Trim(),
            Yazar = istek.Yazar!.Trim(),
            Yil = istek.Yil
        };
        return _kitaplar.TryUpdate(id, yeni, eski);
    }

    public bool Sil(int id) => _kitaplar.TryRemove(id, out _);
}

Neden List<Kitap> değil de ConcurrentDictionary? Web sunucusu aynı anda birden fazla isteği farklı thread'lerde işler. Tek bir depo nesnesini paylaşan istekler sıradan bir listeyi bozabilir. Interlocked.Increment de aynı nedenle kullanılıyor: iki eşzamanlı POST isteği aynı Id'yi almamalı.

Endpoint'ler: MapGet, MapPost, MapPut, MapDelete

Şimdi Program.cs'in üst kısmı. Dosyanın başına iki using ekleyin ve servisi kaydedin:

using System.Collections.Concurrent;
using Microsoft.AspNetCore.Http.HttpResults;

var builder = WebApplication.CreateBuilder(args);

// Uygulama boyunca tek bir depo örneği yaşasın
builder.Services.AddSingleton<IKitapDeposu, BellekKitapDeposu>();

var app = builder.Build();

app.MapGet("/", () => "Kitap API çalışıyor");

var kitaplar = app.MapGroup("/kitaplar");

// GET /kitaplar  ve  GET /kitaplar?yazar=orwell
kitaplar.MapGet("/", (string? yazar, IKitapDeposu depo) =>
    TypedResults.Ok(depo.Listele(yazar)));

// GET /kitaplar/3
kitaplar.MapGet("/{id:int}", Results<Ok<Kitap>, NotFound> (int id, IKitapDeposu depo) =>
    depo.Bul(id) is { } kitap
        ? TypedResults.Ok(kitap)
        : TypedResults.NotFound());

// POST /kitaplar
kitaplar.MapPost("/", Results<Created<Kitap>, ValidationProblem> (KitapIstegi istek, IKitapDeposu depo) =>
{
    var hatalar = Dogrula(istek);
    if (hatalar.Count > 0)
        return TypedResults.ValidationProblem(hatalar);

    var kitap = depo.Ekle(istek);
    return TypedResults.Created($"/kitaplar/{kitap.Id}", kitap);
});

// PUT /kitaplar/3
kitaplar.MapPut("/{id:int}", Results<NoContent, NotFound, ValidationProblem> (int id, KitapIstegi istek, IKitapDeposu depo) =>
{
    var hatalar = Dogrula(istek);
    if (hatalar.Count > 0)
        return TypedResults.ValidationProblem(hatalar);

    return depo.Guncelle(id, istek)
        ? TypedResults.NoContent()
        : TypedResults.NotFound();
});

// DELETE /kitaplar/3
kitaplar.MapDelete("/{id:int}", Results<NoContent, NotFound> (int id, IKitapDeposu depo) =>
    depo.Sil(id)
        ? TypedResults.NoContent()
        : TypedResults.NotFound());

app.Run();

MapGroup("/kitaplar") ortak yol önekini tek yerde toplar. Her Map... metodu bir HTTP fiiline karşılık gelir: GET okur, POST oluşturur, PUT günceller, DELETE siler.

Route Parametreleri ve Model Binding

Lambda'nın parametrelerini framework doldurur; buna model binding denir. Yukarıdaki kodda dört farklı kaynak var:

  • int id: route şablonundaki {id:int} parçasından gelir. İsimlerin birebir aynı olması gerekir. :int bir route kısıtıdır; /kitaplar/abc isteği hiç endpoint'e ulaşmadan 404 alır.
  • string? yazar: route'ta olmadığı için query string'den (?yazar=orwell) okunur. Nullable olduğu için isteğe bağlıdır.
  • KitapIstegi istek: karmaşık tip olduğu için istek gövdesindeki JSON'dan okunur.
  • IKitapDeposu depo: servis olarak kayıtlı olduğu için dependency injection'dan gelir.

Kaynağı elle belirtmek isterseniz [FromRoute], [FromQuery], [FromHeader], [FromBody] ve [FromServices] attribute'ları var. JSON tarafında varsayılan ayar camelCase'tir: Baslik property'si dışarıya baslik olarak çıkar, okurken büyük/küçük harf farkı gözetilmez.

Results ve TypedResults

Bir endpoint düz bir nesne dönebilir; framework onu 200 koduyla JSON'a çevirir. Durum kodunu sizin seçmeniz gerektiğinde iki yardımcı sınıf var. Results.Ok(...), Results.NotFound() gibi metotlar IResult döner. TypedResults aynı metotları sunar ama somut tip döner (Ok<Kitap>, NotFound). Bunun iki faydası olur: endpoint'in hangi cevapları verebileceği imzada görünür ve OpenAPI dokümanı bu bilgiyi otomatik alır; ayrıca unit test yazarken dönen tipi doğrudan kontrol edebilirsiniz.

Bir endpoint birden fazla tip dönebiliyorsa lambda'nın dönüş tipini Results<Ok<Kitap>, NotFound> şeklinde açıkça yazarsınız. Örnekteki lambda'ların başındaki garip görünen ifade budur.

Dependency Injection

builder.Services.AddSingleton<IKitapDeposu, BellekKitapDeposu>() satırı şunu söyler: "Biri IKitapDeposu isterse ona BellekKitapDeposu ver ve uygulama boyunca aynı nesneyi kullan." Endpoint'ler somut sınıfı hiç bilmez. Yarın Entity Framework kullanan bir SqlKitapDeposu yazarsanız değişecek tek yer bu satırdır.

Üç yaşam süresi vardır: AddSingleton (uygulama boyunca tek nesne), AddScoped (her HTTP isteği için bir nesne) ve AddTransient (her istendiğinde yeni nesne). Bellekte veri tutan servisimiz için singleton şart. Veritabanı bağlamı gibi istek bazlı kaynaklarda ise genellikle scoped kullanılır. Servis sınıflarının kendi bağımlılıkları da aynı mekanizmayla, constructor parametreleri üzerinden verilir.

Doğrulamanın Temelleri

İstemciden gelen veriye güvenmeyin. En basit ve her sürümde çalışan yöntem elle kontrol edip ValidationProblem dönmektir. Bu yerel fonksiyonu app.Run(); satırından sonra, tip tanımlarından önce yazın:

static Dictionary<string, string[]> Dogrula(KitapIstegi istek)
{
    var hatalar = new Dictionary<string, string[]>();

    if (string.IsNullOrWhiteSpace(istek.Baslik))
        hatalar["baslik"] = ["Başlık boş olamaz."];

    if (string.IsNullOrWhiteSpace(istek.Yazar))
        hatalar["yazar"] = ["Yazar boş olamaz."];

    if (istek.Yil < 1450 || istek.Yil > DateTime.Now.Year)
        hatalar["yil"] = ["Yıl 1450 ile içinde bulunduğumuz yıl arasında olmalı."];

    return hatalar;
}

ValidationProblem, 400 koduyla ve application/problem+json tipinde standart bir hata gövdesi üretir; istemci hangi alanın neden reddedildiğini errors nesnesinden okur. [Required], [Range] gibi DataAnnotations attribute'larının Minimal API'de otomatik çalışıp çalışmaması kullandığınız .NET sürümüne bağlıdır: yeni sürümlerde yerleşik destek eklendi, eski sürümlerde endpoint filter ya da FluentValidation gibi bir kütüphane gerekir. Hedeflediğiniz sürümün dokümantasyonuna bakın.

OpenAPI Dokümanı

API'nizi kullanacak kişiler için endpoint'leri, parametreleri ve cevap tiplerini anlatan bir OpenAPI (eski adıyla Swagger) dokümanı üretebilirsiniz. Burada tek bir "doğru paket" söylemeyeceğim, çünkü yerleşik araçlar .NET sürümleri arasında değişti: proje şablonları bir dönem üçüncü parti bir Swagger kütüphanesiyle geliyordu, sonraki sürümlerde Microsoft'un kendi OpenAPI doküman üretimi öne çıktı ve arayüz kısmı ayrı bir tercihe bırakıldı. En güvenilir yol, kurulu SDK'nızla dotnet new webapi çalıştırıp şablonun neyi nasıl bağladığına bakmak ve o sürümün resmî dokümantasyonunu izlemektir. Hangi aracı seçerseniz seçin, TypedResults ve açık dönüş tipleri dokümanın doğru çıkmasını sağlar.

Mini Senaryo: API'yi curl ve .http Dosyasıyla Test Etmek

Uygulama çalışırken ikinci bir terminal açın. Bir kitap ekleyelim:

curl -i -X POST http://localhost:5000/kitaplar \
  -H "Content-Type: application/json" \
  -d '{"baslik":"1984","yazar":"George Orwell","yil":1949}'

Cevap 201 Created olur, Location: /kitaplar/1 başlığı gelir ve gövdede oluşturulan kitap döner:

{"id":1,"baslik":"1984","yazar":"George Orwell","yil":1949}

Diğer istekler:

curl "http://localhost:5000/kitaplar?yazar=orwell"
curl -i http://localhost:5000/kitaplar/99          # 404 Not Found
curl -i -X DELETE http://localhost:5000/kitaplar/1  # 204 No Content

Geçersiz veri gönderdiğinizde ("baslik":"", "yil":3000) 400 koduyla birlikte şu gövdeyi alırsınız:

{
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "baslik": ["Başlık boş olamaz."],
    "yil": ["Yıl 1450 ile içinde bulunduğumuz yıl arasında olmalı."]
  }
}

Komutları her seferinde yazmak yerine projeye bir kitaplar.http dosyası ekleyebilirsiniz. Visual Studio, Rider ve VS Code (REST Client eklentisiyle) bu dosyadaki istekleri tek tıkla gönderir:

@host = http://localhost:5000

GET {{host}}/kitaplar

###

POST {{host}}/kitaplar
Content-Type: application/json

{
  "baslik": "Hayvan Çiftliği",
  "yazar": "George Orwell",
  "yil": 1945
}

###

PUT {{host}}/kitaplar/1
Content-Type: application/json

{
  "baslik": "Bin Dokuz Yüz Seksen Dört",
  "yazar": "George Orwell",
  "yil": 1949
}

Bu dosyayı repoya eklerseniz projeyi açan herkes API'yi nasıl deneyeceğini hazır bulur.

Ne Zaman Kullanılır, Ne Zaman Kullanılmaz?

Minimal API şu durumlarda iyi bir seçimdir: az sayıda endpoint'i olan servisler, mikroservisler, mobil uygulama arka uçları, prototipler ve ASP.NET Core'u öğrenme aşaması. Az kod, az kavram ve hızlı başlangıç sunar.

Alternatifi controller tabanlı Web API'dir (webapi şablonunun controller'lı hali ya da MVC). Onlarca endpoint'i olan, filtreler, özel model binder'lar ve yerleşik ekip alışkanlıkları bulunan büyük projelerde controller yapısı düzeni kendiliğinden getirir. Minimal API ile de büyük proje yazılır, ama o zaman düzeni sizin kurmanız gerekir: endpoint'leri MapGroup ile bölün ve her grubu ayrı bir dosyadaki extension metoda taşıyın. Her şeyi Program.cs'e yığmak küçük projede rahat, büyük projede sorun olur.

Sık Yapılan Hatalar

1. Bellek içi servisi AddScoped ile kaydetmek

Belirti: POST isteği 201 döner ama ardından gelen GET boş liste verir. Neden: her istekte yeni bir depo nesnesi oluşur ve veri onunla birlikte kaybolur. Çözüm: durumu bellekte tutan servis için AddSingleton kullanın.

2. Route parametresiyle lambda parametresinin adını farklı yazmak

kitaplar.MapGet("/{id:int}", (int kitapId) => kitapId);   // Yanlış
kitaplar.MapGet("/{id:int}", (int id) => id);             // Doğru

Belirti: her istek 400 Bad Request döner. Framework kitapId'yi route'ta bulamayınca query string'de arar, orada da bulamayınca isteği reddeder; geliştirme ortamı loglarında zorunlu parametrenin query string'de bulunamadığını söyleyen bir mesaj görürsünüz.

3. POST isteğinde Content-Type başlığını unutmak

Belirti: 415 Unsupported Media Type. curl, -d ile gönderilen veriyi varsayılan olarak form verisi sayar. Çözüm: -H "Content-Type: application/json" ekleyin.

4. Farklı TypedResults tiplerini dönüş tipi yazmadan dönmek

// Derlenmez
kitaplar.MapGet("/{id:int}", (int id, IKitapDeposu depo) =>
    depo.Bul(id) is { } kitap ? TypedResults.Ok(kitap) : TypedResults.NotFound());

Belirti oldukça yanıltıcıdır: CS1661: Cannot convert lambda expression to type 'RequestDelegate'.... Asıl neden, Ok<Kitap> ile NotFound'un ortak bir tipi olmadığı için derleyicinin lambda'ya tip verememesidir. Çözüm: dönüş tipini Results<Ok<Kitap>, NotFound> olarak yazın ya da Results.Ok / Results.NotFound kullanın.

Sık Sorulan Sorular

Minimal API gerçek projelerde kullanılır mı, yoksa sadece demo için mi?

Gerçek projelerde de kullanılır; routing, dependency injection, kimlik doğrulama ve middleware altyapısı controller'larla aynıdır. Fark, kodun nasıl organize edildiğindedir ve bu sorumluluk Minimal API'de size kalır.

Results ile TypedResults arasında hangisini seçmeliyim?

Yeni kodda TypedResults öneririm: dönüş tipleri imzada görünür, OpenAPI dokümanı daha doğru çıkar ve test yazmak kolaylaşır. Results ise dönüş tipini yazmak istemediğiniz hızlı denemelerde pratiktir.

Uygulamayı yeniden başlatınca veriler neden kayboluyor?

Çünkü veriyi yalnızca bellekte tutuyoruz. Kalıcılık için IKitapDeposu interface'ini bir veritabanı kullanan yeni bir sınıfla uygulayıp kayıt satırını değiştirmeniz yeterlidir; endpoint'ler aynı kalır.

Minimal API'de async endpoint yazabilir miyim?

Evet. Lambda'yı async yapıp Task<...> dönmeniz yeterlidir; veritabanı ya da HTTP çağrısı yapan endpoint'lerde doğru yol da budur.

Yorumlar