JSON, Content Negotiation ve Formatter'lar: JsonOptions, Enum'lar, Null'lar, XML, CSV Output Formatter ve JSON Patch
ASP.NET Core'da minimal API JSON ayarlarını ConfigureHttpJsonOptions'tan, controller'lar AddJsonOptions'tan okur; ikisini tek yerden ayarlamazsan aynı enum bir tarafta string, öbür tarafta sayı çıkar. Web varsayılanlarını, null ve döngüleri, source generation'ı, content negotiation'ı, kendi CSV formatter'ımızı ve güvenli JSON Patch'i gerçek çıktılarla görüyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.404) ve Microsoft.AspNetCore.JsonPatch.SystemTextJson 10.0.15 ile derlenip denenmiştir.
Önce Tahmin Et
Bir önceki yazıda sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) sipariş JSON'unda "status":0 görmüştük. Enum'u string olarak yazmak için minimal API'nin JSON ayarlarına bir converter ekledik:
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)));
Aynı uygulamada bir de controller var ve aynı enum'u dönüyor:
[HttpGet("api/mvc/order-status")]
public object Get() => new { Order = 1001, Status = OrderStatus.Shipped };
GET /api/mvc/order-status ne döner: "status":"shipped" mi, "status":1 mi?
Bu yazıda response'un ve request'in JSON tarafına bakıyoruz: System.Text.Json ayarlarının iki ayrı yerde durması, web varsayılanları, enum'lar, null'lar ve döngüler, source generation, controller'larda content negotiation, kendi CSV formatter'ımız ve JSON Patch. System.Text.Json'ın kendisi, yani converter yazmak ve polymorphism, .NET'te I/O İşlemleri serisinin System.Text.Json yazılarında ayrıntılı; burada ASP.NET Core'a bağlandığı yerlerle ilgileniyoruz.
İki Taraf, İki JsonOptions
Tahminin cevabı:
$ curl localhost:5238/api/minimal/order-status
{"order":1001,"status":"shipped"}
$ curl localhost:5238/api/mvc/order-status
{"order":1001,"status":1}
Minimal API ile MVC JSON ayarlarını ayrı yerlerden okuyor. ConfigureHttpJsonOptions, Microsoft.AspNetCore.Http.Json.JsonOptions'ı ayarlıyor; minimal API'ler, WriteAsJsonAsync ve ReadFromJsonAsync bunu kullanıyor. Controller'lar ise Microsoft.AspNetCore.Mvc.JsonOptions'a bakıyor ve onu AddControllers().AddJsonOptions(...) ile ayarlıyorsun. Aynı uygulamada ikisi de varsa ayarları tek bir yerde toplayıp iki tarafa da uygulamak en güvenlisi:
public static class JsonDefaults
{
// Minimal API ve controller'lar aynı ayarları buradan alır
public static void Apply(JsonSerializerOptions options)
{
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
options.TypeInfoResolverChain.Insert(0, BookstoreJsonContext.Default);
}
}
// Program.cs
builder.Services.ConfigureHttpJsonOptions(options => JsonDefaults.Apply(options.SerializerOptions));
builder.Services.AddControllers(options =>
{
options.ReturnHttpNotAcceptable = true;
options.OutputFormatters.Add(new BookCsvOutputFormatter());
options.FormatterMappings.SetMediaTypeMappingForFormat("csv", "text/csv");
})
.AddJsonOptions(options => JsonDefaults.Apply(options.JsonSerializerOptions))
.AddXmlSerializerFormatters();
İki JsonOptions class'ı aynı adı taşıdığı için using satırlarında karışmaları da kolay; yanlış namespace'i import edip Configure<JsonOptions> yazarsan derleme hata vermez, ayar sadece öbür tarafa gider. Bu iki satırdan sonra sipariş JSON'u değişti:
{"id":1001,"customer":"ada@example.com","lines":[{"isbn":"978-0441013593","title":"Dune","quantity":2,"unitPrice":450}],"total":900,"status":"placed"}
Web Varsayılanları ve Null'lar
ASP.NET Core, System.Text.Json'ı JsonSerializerDefaults.Web ile başlatır. Bu üç şey demek: property adları camelCase yazılır, okurken büyük-küçük harf önemsenmez ve sayılar string olarak da okunabilir. Sonuncusu çoğu zaman fark edilmez. Yukarıdaki siparişi aslında "quantity":"2" göndererek oluşturdum ve istek sorunsuz geçti. JavaScript tarafında sayıyı string'e çeviren bir form kütüphanesi için bu rahatlık, ama contract'ın gevşek olduğu da anlamına geliyor. .NET 11 yazısında NumberHandling'i Strict yapınca aynı tür isteğin 400 döndüğünü görmüştük.
DefaultIgnoreCondition = WhenWritingNull null değerli property'leri response'tan çıkarır. Kitaplara opsiyonel bir Series alanı ekledik; yalnızca Dune'da dolu:
$ curl "localhost:5238/api/books?pageSize=2"
[{"id":1,"isbn":"978-0441013593","title":"Dune","price":450,"category":"science-fiction","series":"Dune Chronicles"},{"id":2,"isbn":"978-0141439587","title":"Emma","price":280,"category":"classics"}]
Emma'da series hiç yok. Payload küçülür ama istemci "alan yok" ile "alan null" arasındaki farkı kaybeder. Angular tarafında book.series === undefined ile book.series === null farklı sonuç verir; bu ayarı açmadan önce istemci ekibiyle konuş. Tek bir property için [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] attribute'u da var.
Döngüsel Referanslar
EF Core entity'lerini doğrudan döndürdüğünde en sık görülen hata budur. Yazar ve kitaplarını birbirine bağlayan iki class'ı minimal API'den döndürdüğümde Production'da body'siz bir 500 geldi, log'da şu vardı (kısalttım):
System.Text.Json.JsonException: A possible object cycle was detected. This can either be due to a cycle or if the object depth is larger than the maximum allowed depth of 64. Consider using ReferenceHandler.Preserve on JsonSerializerOptions to support cycles. Path: $.Books.Author.Books.Author.Books.Author...
İki çözümü denedim. ReferenceHandler.IgnoreCycles döngüyü kapatan referansı null yapıyor; WhenWritingNull açık olduğu için author tamamen kayboldu. ReferenceHandler.Preserve ise $id ve $ref metadata'sı ekliyor:
IgnoreCycles: {"name":"Frank Herbert","books":[{"title":"Dune"}]}
Preserve: {"$id":"1","name":"Frank Herbert","books":{"$id":"2","$values":[{"$id":"3","title":"Dune","author":{"$ref":"1"}}]}}
Preserve uygulamadaki her response'u etkiliyor: kitap listesi bile {"$id":"1","$values":[...]} şekline döndü ve Angular istemcisini kırdı. İkisi de hatayı saklıyor; asıl çözüm DTO yazısında konuştuğumuz gibi entity'yi değil, döngüsü olmayan bir DTO'yu döndürmek.
Source Generation'ı API'ye Bağlamak
System.Text.Json varsayılan olarak tipleri çalışma anında reflection'la inceler. Source generator ise serialization kodunu derleme anında üretir; açılış süresini kısaltır ve Native AOT için gereklidir:
// Derleme anında üretilen serializer; reflection yerine bu kod kullanılır
[JsonSerializable(typeof(IEnumerable<Book>))]
[JsonSerializable(typeof(OrderDto))]
[JsonSerializable(typeof(OrderDto[]))]
public partial class BookstoreJsonContext : JsonSerializerContext;
JsonDefaults.Apply içindeki TypeInfoResolverChain.Insert(0, BookstoreJsonContext.Default) satırı context'i zincirin başına koyuyor: bu tipler için üretilen kod, diğer tipler için zincirdeki reflection tabanlı resolver kullanılıyor. Context'e kaydettiğimiz OrderDto'nun status'u yine "placed" geldi; converter'lar ve naming policy options'tan okunduğu için source-generated tipler de aynı ayarlarla yazıldı. Native AOT'a gidiyorsan zincirde reflection'a düşen tip kalmamalı; CreateSlimBuilder ve AOT'un kuralları .NET 8 yazısında, source generation'ın ayrıntıları I/O serisinin ikinci System.Text.Json yazısında.
Content Negotiation ve [Produces]
Minimal API'de content negotiation yok: bir object döndürürsen JSON yazılır. Accept header'ına göre farklı format istiyorsan controller'ların formatter altyapısına ihtiyacın var. Status code'lar yazısında kısaca değinmiştik; şimdi bir export endpoint'i üzerinden bakalım:
[ApiController]
[Route("api/books/export")]
public class BookExportController(BookCatalog catalog) : ControllerBase
{
[HttpGet]
[HttpGet("~/api/books/export.{format}")]
[FormatFilter]
[Produces("application/json", "text/csv")]
public IEnumerable<Book> Get() => catalog.All.OrderBy(b => b.Title);
}
MVC, Accept header'ındaki medya tiplerini sırayla dener ve onları yazabilen ilk output formatter'ı seçer. [Produces] bu endpoint'in yalnızca JSON ve CSV ürettiğini söyler; hem seçimi daraltır hem de OpenAPI'ye yansır. ReturnHttpNotAcceptable = true olmasaydı desteklenmeyen bir Accept geldiğinde MVC sessizce JSON'a düşerdi; açık olduğu için 406 dönüyor:
$ curl -i localhost:5238/api/books/export -H "Accept: application/pdf"
HTTP/1.1 406 Not Acceptable
Content-Length: 0
$ curl -i localhost:5238/api/books/export -H "Accept: application/xml"
HTTP/1.1 406 Not Acceptable
Content-Type: application/problem+xml; charset=utf-8
<problem xmlns="urn:ietf:rfc:7807"><status>406</status><title>Not Acceptable</title><type>https://tools.ietf.org/html/rfc9110#section-15.5.7</type><traceId>00-1819b16ee31567f60cd8ec08d702258d-30428eba5f90d0b8-00</traceId></problem>
İkinci çıktı ilginç: endpoint XML üretmiyor ama uygulamada XML formatter kayıtlı olduğu için 406'nın ProblemDetails gövdesi XML yazıldı. Tarayıcıdan gelen isteklerde ise MVC Accept'i varsayılan olarak yok sayıyor (RespectBrowserAcceptHeader false): tarayıcıların gönderdiği text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 header'ıyla curl'den attığım istek 406 değil, JSON aldı; MVC */* görünce isteği tarayıcıdan gelmiş sayıyor. Request tarafında [Consumes] aynı işi yapar: desteklemediğin bir Content-Type gelirse 415 döner.
XML ve Record'lar
AddXmlSerializerFormatters() XML'i XmlSerializer ile yazar ve bu serializer'ın eski bir kuralı var: parametresiz constructor ister. [Produces] olmayan bir action'dan Book record'unu XML olarak istediğimde 406 geldi, log'da sebebi vardı:
warn: Microsoft.AspNetCore.Mvc.Formatters.XmlSerializerOutputFormatter[2]
An error occurred while trying to create an XmlSerializer for the type 'Microsoft.AspNetCore.Mvc.Formatters.Xml.DelegatingEnumerable`2[...]'.
System.InvalidOperationException: Bookstore.Api.Catalog.Book cannot be serialized because it does not have a parameterless constructor.
Positional record'lar bu kurala uymuyor. XML'e gerçekten ihtiyacın varsa parametresiz constructor'ı ve set edilebilir property'leri olan ayrı bir export DTO'su yaz ya da AddXmlDataContractSerializerFormatters() ile DataContract yolunu dene.
Kendi CSV Formatter'ın
Kurumsal müşteriler katalog listesini Excel'de açmak istiyor. Aynı endpoint'ten CSV dönmek için TextOutputFormatter'dan türeyen bir formatter yazıyoruz:
public class BookCsvOutputFormatter : TextOutputFormatter
{
public BookCsvOutputFormatter()
{
SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/csv"));
SupportedEncodings.Add(Encoding.UTF8);
}
// Yalnız kitap listelerini yazabiliriz; diğer tipler için sıradaki formatter'a geçilir
protected override bool CanWriteType(Type? type) =>
typeof(IEnumerable<Book>).IsAssignableFrom(type);
public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
{
StringBuilder csv = new();
csv.AppendLine("isbn,title,price,category");
foreach (Book book in (IEnumerable<Book>)context.Object!)
{
csv.AppendLine(string.Join(',',
book.Isbn,
Escape(book.Title),
book.Price.ToString(CultureInfo.InvariantCulture),
book.Category));
}
context.HttpContext.Response.Headers.ContentDisposition = "attachment; filename=books.csv";
await context.HttpContext.Response.WriteAsync(csv.ToString(), selectedEncoding);
}
private static string Escape(string value) =>
value.Contains(',') || value.Contains('"') ? $"\"{value.Replace("\"", "\"\"")}\"" : value;
}
$ curl -i localhost:5238/api/books/export -H "Accept: text/csv"
HTTP/1.1 200 OK
Content-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename=books.csv
isbn,title,price,category
978-0441013593,Dune,450,science-fiction
978-0141439587,Emma,280,classics
978-0141439549,Middlemarch,390,classics
978-0547928227,The Hobbit,320,science-fiction
$ curl localhost:5238/api/books/export.csv
isbn,title,price,category
978-0441013593,Dune,450,science-fiction
...
Accept header'ı olmayan istekte aynı endpoint JSON döndü. [FormatFilter] ve SetMediaTypeMappingForFormat("csv", "text/csv") sayesinde export.csv gibi bir URL de Accept yerine geçiyor; header gönderemeyen bir link için kullanışlı. Fiyatı InvariantCulture ile yazmak burada şart: Türkçe kültürde 450,5 gibi bir değer CSV'nin ayırıcısıyla çakışır. CanWriteType da önemli: formatter yalnızca kitap listelerini yazıyor, ProblemDetails gibi diğer tipler için sıradaki formatter'a yer bırakıyor. Minimal API tarafında aynı ihtiyaç için bir önceki yazıdaki gibi kendi IResult'ını yazarsın.
JSON Patch: Yalnız İzin Verilen Path'ler
.NET 10 yazısında System.Text.Json tabanlı JSON Patch paketini bir controller'da görmüştük. Bu sefer minimal API'de ve Learn'ün güvenlik bölümündeki önerilerle yazıyoruz. Learn açık: JSON Patch standardının kendine özgü riskleri var ve ASP.NET Core bunları sizin yerinize azaltmaya çalışmıyor. Biz üç şey yaptık: patch'i entity'ye değil küçük bir DTO'ya uyguluyoruz, değiştirilebilecek path'leri listeyle sınırlıyoruz ve sonucu uygulamadan sonra doğruluyoruz:
public class BookPatch
{
[Range(1, 10000)]
public decimal Price { get; set; }
public string Title { get; set; } = "";
}
public static class BookPatchEndpoint
{
// İstemcinin değiştirebileceği path'ler; Title DTO'da olsa da burada yok
private static readonly string[] AllowedPaths = ["/price"];
public static Results<Ok<Book>, NotFound, ValidationProblem> Patch(
Isbn isbn, JsonPatchDocument<BookPatch> patch, BookCatalog catalog)
{
if (catalog.Find(isbn) is not { } book)
{
return TypedResults.NotFound();
}
Dictionary<string, string[]> errors = [];
foreach (Operation<BookPatch> operation in patch.Operations)
{
if (operation.OperationType != OperationType.Replace
|| !AllowedPaths.Contains(operation.path, StringComparer.OrdinalIgnoreCase))
{
errors[operation.path] = [$"'{operation.op}' on '{operation.path}' is not allowed."];
}
}
if (errors.Count > 0)
{
return TypedResults.ValidationProblem(errors);
}
BookPatch target = new() { Price = book.Price, Title = book.Title };
patch.ApplyTo(target, error => errors[error.Operation.path] = [error.ErrorMessage]);
List<ValidationResult> results = [];
Validator.TryValidateObject(target, new ValidationContext(target), results, validateAllProperties: true);
foreach (ValidationResult result in results)
{
errors[result.MemberNames.First()] = [result.ErrorMessage!];
}
if (errors.Count > 0)
{
return TypedResults.ValidationProblem(errors);
}
Book updated = book with { Price = target.Price };
catalog.Replace(updated);
return TypedResults.Ok(updated);
}
}
// Program.cs
app.MapPatch("/api/books/{isbn}", BookPatchEndpoint.Patch)
.RequireAuthorization(policy => policy.RequireRole("StoreStaff"));
Fiyatı yalnızca mağaza personeli değiştirebiliyor; token'ı dotnet user-jwts create --name grace@example.com --role StoreStaff ile ürettim:
$ curl -X PATCH localhost:5238/api/books/978-0441013593 -H "Authorization: Bearer $STAFF_TOKEN" -H "Content-Type: application/json-patch+json" -d '[{"op":"replace","path":"/price","value":420}]'
{"id":1,"isbn":"978-0441013593","title":"Dune","price":420,"category":"science-fiction","series":"Dune Chronicles"}
$ curl -X PATCH ... -d '[{"op":"replace","path":"/title","value":"Dune Messiah"},{"op":"remove","path":"/price"}]'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"/title":["'replace' on '/title' is not allowed."],"/price":["'remove' on '/price' is not allowed."]},"traceId":"00-5272def1ddc0c86c0cdc5df50620b288-43637c9562a91fef-00"}
$ curl -X PATCH ... -d '[{"op":"replace","path":"/price","value":0}]'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Price":["The field Price must be between 1 and 10000."]},"traceId":"00-509a3f16a1e2af6a6c5d893ed1205ac4-96eff70a3193f4c9-00"}
Customer rolündeki Ada'nın token'ıyla aynı istek 403 döndü. Content-Type: application/json ile gönderdiğimde ise 415 aldım; minimal API JsonPatchDocument<T> parametresi için yalnız application/json-patch+json kabul ediyor. DTO'da Title olduğu halde listede olmadığı için reddedildi; bu, Id ya da CreatedBy gibi istemcinin dokunmaması gereken alanları korumanın en basit yolu. Learn ayrıca çok sayıda copy operation'ıyla bellek şişirme riskine dikkat çekiyor; replace dışındaki her şeyi reddettiğimiz için bizde bu kapı kapalı.
.NET 10 öncesinin yolu Newtonsoft'tu: Microsoft.AspNetCore.Mvc.NewtonsoftJson paketi ve AddNewtonsoftJson(). Bu çağrı yalnızca JSON Patch'i değil MVC'deki bütün JSON okuma ve yazmayı Newtonsoft'a taşır; bu yüzden Learn, eski sürümlerde yalnızca NewtonsoftJsonPatchInputFormatter'ı input formatter listesinin başına ekleyip geri kalanını System.Text.Json'da bırakan bir yol gösteriyordu. Eski bir projede bu kodu görürsen sebebi budur; yeni kodda System.Text.Json paketi yeterli, ExpandoObject gibi dynamic tiplere patch uygulaman gerekmiyorsa.
Sık Düşülen Tuzaklar
Birincisi, minimal API ile controller'ın farklı JSON ayarlarıyla çalışması. Önce Tahmin Et'te gördük: aynı enum bir tarafta string, öbür tarafta sayı. Ayarları tek bir method'da topla ve iki yere de uygula.
İkincisi, enum'u sayı olarak dışarı açıp sonra kırmak. "status":0 yayınlandıktan sonra enum'a araya yeni bir değer eklersen (örneğin Placed ile Shipped arasına Packed) Shipped artık 2 olur ve eski istemciler siparişi yanlış gösterir. Sayıdan string'e geçmek de istemci açısından breaking change. Kararı ilk sürümde ver; string seçtiysen enum üyelerinin adlarını da contract'ın parçası say.
Üçüncüsü, ReferenceHandler.Preserve'ü "döngü hatası gitsin" diye açmak. Bütün response'ların şekli değişir ve istemci $values ile uğraşmak zorunda kalır. Döngüyü DTO ile kır.
Dördüncüsü, JSON Patch'i entity'ye doğrudan uygulamak. Bir path listesi ve DTO olmadan istemci /id'yi ya da /customerId'yi değiştirmeyi deneyebilir; kontrol tamamen sana kalmış.
Küçük Bir Deneme
★☆☆ JsonDefaults.Apply'dan WhenWritingNull satırını sil ve /api/books çıktısında Emma için "series":null göründüğünü doğrula. Sonra yalnızca Series property'sine [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ekle.
★★☆ BookCsvOutputFormatter'ı IEnumerable<OrderDto> için de çalışacak şekilde genelleştir ve sipariş listesini CSV olarak dönen bir controller action'ı yaz. Hangi formatter'ın seçildiğini Accept header'ını değiştirerek gözlemle.
★★★ JSON Patch endpoint'ine test operation'ı için izin ver: istemci [{"op":"test","path":"/price","value":420},{"op":"replace","path":"/price","value":399}] gönderdiğinde fiyat 420 değilse değişiklik uygulanmasın. Bunun bir optimistic concurrency (iyimser eşzamanlılık kontrolü) aracı olarak nasıl kullanılabileceğini düşün.
Bu yazıdaki her endpoint'in önünde aynı middleware zinciri duruyor ama o zincirde neler eksik, henüz bakmadık. Bir sonraki yazıda built-in middleware'leri tek tek ekliyoruz: response compression, request decompression, request timeouts, status code pages, URL rewrite ve header propagation.