Mert Özen Her Satırda Daha İleri
Backend .NET Core Web API — #25

ASP.NET Core Web API Versioning: Asp.Versioning ile URL, Query String ve Header

Mert Özen 4 Kas 2026 8 dk 1 görüntülenme
ASP.NET Core Web API Versioning: Asp.Versioning ile URL, Query String ve Header

Breaking change'leri eski istemcileri kırmadan yayınlamak için Asp.Versioning.Mvc ile iki sürümlü bir API kuruyoruz. URL, query string ve header okuyucularını, deprecation ve Sunset header'larını deniyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 6 ile yazılmıştır ve .NET 10'da da değişmeden çalışır.

Önce Tahmin Et

Bir önceki yazının sonunda bir soruyla kalmıştık: ProductDto'nun şeklini değiştirmen gerektiğinde, eski hâline bağlı mobil uygulamayı nasıl kırmayacaksın? Bu yazının cevabı API versioning.

Önce tahmin. Birazdan aynı route'u, api/products'ı paylaşan iki controller yazacağız: biri sürüm 1.0, diğeri 2.0. Mobil uygulamanın eski bir sürümü hiçbir sürüm bilgisi göndermeden GET api/products isteği atıyor. Ne olur? İki controller çakıştığı için 500 mü, yeni sürüm olduğu için 2.0 mı, yoksa 1.0 mı?

Neden Versioning?

Her değişiklik istemciyi kırmaz. Response'a yeni bir alan eklemek çoğu zaman güvenlidir; iyi yazılmış istemciler tanımadıkları alanları görmezden gelir. Ama bir alanı silmek, adını değiştirmek ya da tipini değiştirmek breaking change'tir. Bizim örneğimizde price şu an düz bir sayı. Çoklu para birimi desteği için onu { "amount": 1450, "currency": "TRY" } gibi bir object'e çevirmek istiyoruz. Bu değişikliği doğrudan yaparsan, fiyatı sayı bekleyen her istemci bozulur.

Web uygulamasını bir deploy ile güncelleyebilirsin, ama mobil uygulamanın kullanıcıları güncellemeyi haftalarca yapmayabilir. Bir şehrin yeni bir yol açarken eskisini aynı gün kapatmadığını düşün: bir süre ikisi birlikte açık kalır, eski yolun girişine "bu yol 1 Haziran'da kapanacak" tabelası asılır. Versioning de tam olarak bu: yeni sözleşmeyi yanına koymak, eskisini bir süre yaşatmak ve kapanacağını önceden duyurmak.

Paket ve Sürümler

ASP.NET Core'un içinde versioning yok; bunun için .NET Foundation altındaki Asp.Versioning.Mvc paketini kullanıyoruz. Eski projelerde göreceğin Microsoft.AspNetCore.Mvc.Versioning paketi 5.1.0'da kaldı ve NuGet'te deprecated olarak işaretli; açıklamasında yeni paketin Ağustos 2022'den beri mevcut olduğu ve geçiş yapılması gerektiği yazıyor.

Yeni paketin major sürümü .NET sürümünü takip ediyor. NuGet'teki paket tanımlarına baktığımda tablo şöyleydi: 6.4.1 netcoreapp3.1 ve net6.0 hedefliyor, 8.1.1 net8.0, 10.2.1 ise sadece net10.0. Bu yüzden .NET 6 projesine sürümü açıkça vererek ekliyoruz:

dotnet add package Asp.Versioning.Mvc --version 6.4.1
dotnet add package Asp.Versioning.Mvc.ApiExplorer --version 6.4.0

İkinci paket, API Explorer'a sürüm bilgisini taşıyor; bir sonraki yazıda Swagger tarafında ona ihtiyacımız olacak. Bu yazının kodunu hem .NET 6 hem .NET 10 için derlediğim projede iki sürüm grubu şöyle duruyor:

  <ItemGroup Condition="'$(TargetFramework)' == 'net6.0'">
    <PackageReference Include="Asp.Versioning.Mvc" Version="6.4.1" />
    <PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="6.4.0" />
  </ItemGroup>

  <ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
    <PackageReference Include="Asp.Versioning.Mvc" Version="10.2.1" />
    <PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.2.1" />
  </ItemGroup>

Aşağıdaki C# kodu iki sürümde de hiçbir değişiklik ve uyarı olmadan derlendi.

Program.cs Ayarları

using Asp.Versioning;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

builder.Services
    .AddApiVersioning(options =>
    {
        // Sürüm belirtilmezse 1.0 kabul et
        options.DefaultApiVersion = new ApiVersion(1, 0);
        options.AssumeDefaultVersionWhenUnspecified = true;
        // Desteklenen ve deprecated sürümleri response header'larında bildir
        options.ReportApiVersions = true;
        // Sürüm URL'den, query string'den ya da header'dan okunabilir
        options.ApiVersionReader = ApiVersionReader.Combine(
            new UrlSegmentApiVersionReader(),
            new QueryStringApiVersionReader("api-version"),
            new HeaderApiVersionReader("X-Api-Version"));

        options.Policies.Sunset(1.0)
            .Effective(new DateTimeOffset(2027, 6, 1, 0, 0, 0, TimeSpan.Zero))
            .Link("https://example.com/api/versioning-policy")
                .Title("API versioning policy")
                .Type("text/html");
    })
    .AddMvc()
    .AddApiExplorer(options =>
    {
        // Swagger'da "v1", "v2" gibi grup adları
        options.GroupNameFormat = "'v'VVV";
        options.SubstituteApiVersionInUrl = true;
    });

var app = builder.Build();

app.MapControllers();

app.Run();

AddApiVersioning çekirdek service'leri kaydediyor, .AddMvc() ise bunları controller'lara bağlıyor; eski paketten gelenlerin en sık unuttuğu satır bu. Sunset policy'sini birazdan konuşacağız.

Baştaki tahminin cevabı AssumeDefaultVersionWhenUnspecified satırında. Sürüm bilgisi olmayan istek 1.0 sayılıyor ve eski mobil uygulama hiçbir şey fark etmeden eski formatı almaya devam ediyor. Bu satırı silip aynı isteği tekrar attığımda 400 aldım:

{"type":"https://docs.api-versioning.org/problems#unspecified","title":"Unspecified API version","status":400,"detail":"An API version is required, but was not specified.","traceId":"00-f71f31360a64ea7a91a0f0b1174739ba-ee32c0d3e32ae3b7-00","code":"ApiVersionUnspecified"}

Yeni bir API'de bu katılık iyi bir şey olabilir; istemcileri baştan sürüm göndermeye zorlar. Ama zaten yayında olan ve sürümsüz çağrılan bir API'ye versioning ekliyorsan, varsayılan sürümü mevcut davranışa eşitlemek zorundasın.

İki Sürüm, İki Controller

Sürümleri ayrı namespace'lerde, aynı isimli iki controller olarak tutuyoruz. Sürüm 1.0:

using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;

namespace ProductCatalog.Controllers.V1;

public record ProductDto(int Id, string Name, decimal Price);

[ApiController]
[ApiVersion("1.0", Deprecated = true)]
[Route("api/products")]
[Route("api/v{version:apiVersion}/products")]
public class ProductsController : ControllerBase
{
    [HttpGet]
    public IEnumerable<ProductDto> GetAll() => new[]
    {
        new ProductDto(1, "Mechanical Keyboard", 1450m),
        new ProductDto(2, "Wireless Mouse", 600m)
    };
}

Sürüm 2.0, fiyatı bir object olarak dönüyor:

using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;

namespace ProductCatalog.Controllers.V2;

public record Money(decimal Amount, string Currency);

public record ProductDto(int Id, string Name, Money Price);

[ApiController]
[ApiVersion("2.0")]
[Route("api/products")]
[Route("api/v{version:apiVersion}/products")]
public class ProductsController : ControllerBase
{
    [HttpGet]
    public IEnumerable<ProductDto> GetAll() => new[]
    {
        new ProductDto(1, "Mechanical Keyboard", new Money(1450m, "TRY")),
        new ProductDto(2, "Wireless Mouse", new Money(600m, "TRY"))
    };
}

Normalde iki controller'ın aynı route'u paylaşması "ambiguous match" hatası demek. Versioning paketi route eşleşmesine sürümü de katarak hangi controller'ın seçileceğine kendisi karar veriyor. {version:apiVersion} bir route constraint; URL'deki sürüm parçasını okuyor. GET api/v2/products cevabı:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
api-supported-versions: 2.0
api-deprecated-versions: 1.0

[{"id":1,"name":"Mechanical Keyboard","price":{"amount":1450,"currency":"TRY"}},{"id":2,"name":"Wireless Mouse","price":{"amount":600,"currency":"TRY"}}]

ReportApiVersions sayesinde her cevapta iki header var: api-supported-versions desteklenen sürümleri, api-deprecated-versions ömrü dolmak üzere olanları listeliyor. 1.0'ı deprecated işaretlediğimiz için supported listesinde sadece 2.0 görünüyor; istemci geliştiricisi bir bakışta hangi sürüme geçmesi gerektiğini anlıyor.

URL mi, Query String mi, Header mı?

ApiVersionReader.Combine ile üç yolu birden açtık. Hepsi aynı 2.0 cevabını döndü:

curl http://localhost:5080/api/v2/products
curl "http://localhost:5080/api/products?api-version=2.0"
curl http://localhost:5080/api/products -H "X-Api-Version: 2.0"

URL segment en görünür olanı: tarayıcıda, log'da, dokümantasyonda sürüm hemen göze çarpar ve cache'ler farklı sürümleri farklı adres olarak görür. Bedeli, bir kaynağın birden fazla URL'ye sahip olması. Query string aynı URL'yi korur ve test etmesi kolaydır. Header ise URL'yi tamamen temiz bırakır, ama tarayıcıda denemesi zorlaşır ve cache'lerin header'a göre ayrım yapması gerekir. Tek bir doğru yok; önemli olan ekibin bir tanesini seçip tutarlı kullanması. Ben dışarıya açık API'lerde URL segment'i tercih ediyorum.

Yolları birleştirmenin bir yan etkisi var: istemci iki farklı yerden iki farklı sürüm gönderirse paket tahmin yürütmüyor:

curl "http://localhost:5080/api/products?api-version=1.0" -H "X-Api-Version: 2.0"
{"type":"https://docs.api-versioning.org/problems#ambiguous","title":"Ambiguous API version","status":400,"detail":"The following API versions were requested: 1.0, 2.0. At most, only a single API version may be specified. Please update the intended API version and retry the request.","traceId":"00-7ff54f9d48bd56b1c27ba8aa5c793adb-7a83cd855c71f957-00","code":"AmbiguousApiVersion"}

Olmayan bir sürüm istendiğinde ise cevap yola göre değişiyor. ?api-version=3.0 için "code":"UnsupportedApiVersion" içeren bir 400 ProblemDetails geldi. api/v3/products ise boş body'li bir 404 döndü; route constraint 3.0'ı tanımadığı için ortada eşleşen bir endpoint yok.

Deprecation ve Sunset

[ApiVersion("1.0", Deprecated = true)] sürümü kapatmaz; sadece "bu sürüm ömrünün sonuna yaklaşıyor" der. Sürüm çalışmaya devam eder ve header'larda deprecated olarak listelenir. Ne zaman kapanacağını söylemek için Program.cs'teki sunset policy'sini kullandık. 1.0'a gelen her cevaba artık iki header daha ekleniyor:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
api-supported-versions: 2.0
api-deprecated-versions: 1.0
Sunset: Tue, 01 Jun 2027 00:00:00 GMT
Link: <https://example.com/api/versioning-policy>; rel="sunset"; title="API versioning policy"; type="text/html"

Sunset header'ı RFC 8594'te tanımlı; Link header'ı da istemciyi geçiş rehberine yönlendiriyor. 2.0 cevaplarında bu iki header yok.

Burada canlı bir tuzağa düştüm. İlk denemede tarihi .Effective(2027, 6, 1) overload'uyla vermiştim ve header Sunset: Mon, 31 May 2027 21:00:00 GMT olarak geldi. Bu overload tarihi sunucunun yerel saat dilimine göre yorumluyor; benim makinem UTC+3'te olduğu için üç saat kaydı. Saat dilimi farklı iki sunucu aynı koddan farklı tarihler duyurmasın diye tarihi DateTimeOffset ile, UTC olarak veriyoruz.

Deprecation bir iletişim aracı. Header'ları istemci tarafında loglayan ya da izleyen ekipler bunu otomatik fark eder. Ama asıl iş insan tarafında: duyuru, geçiş rehberi ve hangi istemcinin hâlâ 1.0 çağırdığını gösteren log'lar. Kapatma tarihine kadar 1.0 trafiğini izle; sıfıra yaklaşmadan kapatma.

Sık Düşülen Tuzaklar

Eski paketi kurmak. Microsoft.AspNetCore.Mvc.Versioning hâlâ arama sonuçlarında çıkıyor; yeni projede Asp.Versioning.Mvc kullan. Namespace de değişti: Asp.Versioning.

.AddMvc() satırını unutmak. Çekirdek service'ler kayıtlı olur ama controller'lar sürüm bilgisini görmez.

Varsayılan sürümü sonradan değiştirmek. DefaultApiVersion'ı 2.0 yaptığın gün, sürüm göndermeyen bütün eski istemciler bir anda yeni formatı alır. Varsayılan sürüm, en eski ve en çok kullanılan sözleşmeye bağlı kalmalı.

Her küçük değişiklik için yeni sürüm açmak. Yeni bir alan eklemek breaking değildir. Sürüm sayısı arttıkça bakımı yapılan kod yolu da artar; sürümü gerçekten kıran değişiklikler için sakla.

Paket sürümleri arasındaki küçük farkları gözden kaçırmak. Aynı kodu .NET 10 ve 10.2.1 ile çalıştırdığımda 400 cevaplarının body'si boş geldi; yeni sürümler ProblemDetails üretmek için .NET 7 ile gelen AddProblemDetails() kaydını bekliyor ve onu ekleyince body geri geldi. 10.2.1'de ayrıca RFC 9745'teki Deprecation header'ını üreten bir Deprecate policy'si var. İkisi de .NET 6'da yok; ilerleyen sürüm yazılarında göreceğiz.

Küçük Bir Deneme

★☆☆ 2.0 controller'ına GET api/v2/products/{id} action'ını ekle. Aynı isteği api/v1/products/1 ile atınca ne döndüğüne bak ve nedenini açıkla.

★★☆ Tek bir controller'da iki sürümü birlikte taşı: controller'a hem [ApiVersion("1.0")] hem [ApiVersion("2.0")] ekle, farklı davranan action'ı [MapToApiVersion("2.0")] ile işaretle.

★★★ 1.0 çağrılarını sayan küçük bir middleware ya da filter yaz: istenen sürümü HttpContext.Features.Get<IApiVersioningFeature>()?.RequestedApiVersion ile oku (bu yol 6.4.1'de de 10.2.1'de de derleniyor) ve her 1.0 isteğinde istemcinin User-Agent değerini logla. Kapatma kararını bu verilerle ver.

Artık API'nin birden fazla sözleşmesi var ve istemci geliştiricilerinin her birini bilmesi gerekiyor. Header'lar sürüm listesini veriyor ama endpoint'leri, request ve response şemalarını anlatmıyor. Bir sonraki yazıda Swashbuckle ile Swagger/OpenAPI dokümantasyonunu kuracak, AddApiExplorer ayarının hazırladığı v1 ve v2 gruplarını ayrı Swagger belgeleri olarak göstereceğiz.