Tarayıcıdan ve REST Client'lardan gRPC: gRPC-Web, JSON Transcoding ve Angular
Tarayıcı trailer'lara erişemediği için gRPC'yi doğrudan konuşamaz; gRPC-Web ile Angular'dan typed client'la, JSON transcoding ile de REST bekleyen client'lara aynı service'i açabilirsin. Connect-ES ve resource API'siyle bir stok rozeti yazıyor; CORS, int64'ün JSON'daki hâli, rich error'larda TypeRegistry tuzağı ve deprecated Grpc.Swagger yerine seçenekleri gerçek çıktılarla gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Grpc.AspNetCore.Web 2.85, Microsoft.AspNetCore.Grpc.JsonTranscoding 10.0, Angular 22.2, Connect-ES 2.3 ve Protobuf-ES 2.17 ile derlenip denenmiştir.
Önce Tahmin Et
Kitap detay sayfasında "stokta 12 adet" rozetini göstermek istiyorsun. Veri zaten Inventory'nin GetStock RPC'sinde (başka bir makinedeki fonksiyonu yerel gibi çağırma). Angular uygulaması tarayıcıdan bu RPC'yi doğrudan çağırabilir mi? Tarayıcılar yıllardır HTTP'nin 2. sürümünü konuşuyor; eksik olan ne?
Mobil ekip de aynı veriyi istiyor ama gRPC client'ı eklemek istemiyor, düz REST (Representational State Transfer) ve JSON (JavaScript Object Notation) bekliyor. Aynı service'i JSON olarak açtığında fiyatın units alanı (int64) JSON'da sayı mı gelir, string mi? Bir de: rich error detayı taşıyan bir hata JSON'a çevrilirken ne olur?
Tarayıcı Neden gRPC Konuşamaz?
Sorun HTTP/2'nin kendisi değil, tarayıcının ona nasıl eriştiği. Bir web sayfası HTTP isteğini fetch ya da XMLHttpRequest ile yapar ve bu API'ler HTTP/2'nin frame'lerine erişim vermez. gRPC ise çağrının sonucunu (grpc-status) gövdeden sonra gelen trailer'larda taşır; tarayıcı API'leri trailer'ları sayfaya göstermez. Tarayıcı bir HTTP/2 isteğini hangi bağlantı üzerinden, hangi protokol sürümüyle göndereceğini de kendisi seçer.
gRPC-Web bu boşluğu kapatan küçük bir protokol değişikliği. Trailer'lar gövdenin sonuna özel işaretli bir frame olarak eklenir, böylece fetch ile okunabilir. HTTP/1.1 üzerinde de çalışır. Bedeli: tarayıcıdan yalnız unary ve server streaming kullanılabiliyor; tarayıcılar istek gövdesini akıtmayı desteklemediği için client ve bidirectional streaming yok. Server tarafında ise bir çevirmen gerekir. ASP.NET Core'da bu çevirmen bir middleware ve ayrı bir proxy'ye ihtiyaç yok.
Server Tarafı: UseGrpcWeb, CORS ve İkinci Endpoint
Inventory'ye Grpc.AspNetCore.Web paketini ekleyip middleware'i ve CORS politikasını kuruyoruz:
builder.Services.AddCors(options => options.AddPolicy("bookstore-web", policy => policy
.WithOrigins("http://localhost:4200")
.AllowAnyHeader()
.WithMethods("POST")
.WithExposedHeaders("Grpc-Status", "Grpc-Message", "Grpc-Encoding", "Grpc-Accept-Encoding")));
app.UseRouting();
app.UseGrpcWeb();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapGrpcService<InventoryGrpcService>()
.EnableGrpcWeb()
.RequireCors("bookstore-web");
UseGrpcWeb routing ile endpoint'ler arasına girer ve EnableGrpcWeb() denmiş service'ler için gRPC-Web isteklerini normal gRPC çağrısına çevirir. CORS politikasındaki WithExposedHeaders satırı kritik. Hatasız bir yanıtta status gövdenin sonundadır, ama hatalı yanıtlar çoğu zaman yalnız header'dan oluşur (bir önceki yazılarda gördüğümüz "trailers-only" yanıt) ve tarayıcı, açıkça izin verilmeyen header'ları JavaScript'e göstermez. Bu satır olmadan Angular her hatayı anlamsız bir ağ hatası olarak görür.
Kestrel tarafında da bir ayar var. Mevcut endpoint TLS kullanmıyor ve yalnız HTTP/2 konuşuyor; tarayıcı TLS'siz bir adrese HTTP/2 ile gitmez. Lokalde tarayıcı için ikinci bir endpoint açtık:
"Kestrel": {
"Endpoints": {
"Grpc": {
"Url": "http://localhost:5101",
"Protocols": "Http2"
},
"Web": {
"Url": "http://localhost:5103",
"Protocols": "Http1"
}
}
}
Production'da tek bir TLS'li endpoint (Http1AndHttp2) yeter; ALPN (TLS handshake'inde HTTP/2 gibi protokolün seçilmesi) ikisini ayırır.
Angular'da Connect-ES ile Typed Client
Tarayıcı tarafında gRPC-Web için birkaç client var. Biz Connect-ES'i kullanıyoruz: TypeScript'e özgü kod üretir, gRPC-Web transport'u hazır gelir ve mesajlar düz object olarak kullanılır. Kodu .proto dosyalarından Buf CLI (@bufbuild/buf npm paketiyle gelir) ve Protobuf-ES'in plugin'iyle üretiyoruz. Contract klasörüne bir buf.yaml koyduk; google/api/annotations.proto birazdan JSON transcoding için lazım olacak, o yüzden googleapis'e bağımlılık var:
# protos/buf.yaml
version: v2
deps:
- buf.build/googleapis/googleapis
# bookstore-web/buf.gen.yaml
version: v2
inputs:
- directory: ../../app/protos
plugins:
- local: protoc-gen-es
out: src/gen
opt: target=ts
include_imports: true
npx buf dep update (protos klasöründe, bir kez) ve npx buf generate sonrası src/gen/bookstore/inventory/v1/inventory_pb.ts oluşuyor. İçinde mesaj tipleri ve InventoryService tanımı var. Dikkat çeken bir detay: int64 units TypeScript'te units: bigint olarak geliyor. Protobuf-ES 64 bitlik tamsayıyı JavaScript'in number'ına sığdırmaya çalışmıyor. Client'ı bir injection token ile sağlıyoruz:
import { InjectionToken } from '@angular/core';
import { createClient, type Client } from '@connectrpc/connect';
import { createGrpcWebTransport } from '@connectrpc/connect-web';
import { InventoryService } from '../gen/bookstore/inventory/v1/inventory_pb';
export const INVENTORY_CLIENT = new InjectionToken<Client<typeof InventoryService>>('INVENTORY_CLIENT', {
providedIn: 'root',
factory: () =>
createClient(InventoryService, createGrpcWebTransport({ baseUrl: 'http://localhost:5103' })),
});
Stok rozeti component'i Angular 22'de stable olan resource API'siyle yazıldı. Angular serisinin httpResource yazısındaki modelin aynısı, yalnız loader HTTP isteği yerine bir gRPC çağrısı:
@Component({
selector: 'app-book-stock',
template: `
@if (stock.isLoading()) {
<p>Checking stock...</p>
} @else if (stock.error(); as error) {
<p class="error">{{ describe(error) }}</p>
} @else if (stock.value(); as s) {
<p>{{ label(s.status) }}: {{ s.available }} available, {{ price(s) }} {{ s.unitPrice?.currencyCode }}</p>
<p>Updated {{ updatedAt(s) }}</p>
}
`,
})
export class BookStock {
readonly bookId = input.required<string>();
private readonly inventory = inject(INVENTORY_CLIENT);
protected readonly stock = resource({
params: () => ({ bookId: this.bookId() }),
// abortSignal: bookId değişirse ya da component yok olursa istek iptal edilir
loader: ({ params, abortSignal }) =>
this.inventory.getStock(params, { signal: abortSignal, timeoutMs: 3000 }),
});
protected price(stock: GetStockResponse): string {
const amount = stock.unitPrice?.amount;
// units bigint olarak gelir (int64)
return amount ? (Number(amount.units) + amount.nanos / 1e9).toFixed(2) : '-';
}
protected updatedAt(stock: GetStockResponse): string {
return stock.updatedAt ? timestampDate(stock.updatedAt).toISOString() : '-';
}
protected describe(error: unknown): string {
const err = ConnectError.from(error);
return `${Code[err.code]}: ${err.rawMessage}`;
}
}
label method'u StockStatus enum'unu ekrandaki metne çeviren bir switch; ilk yazıdaki uyarıya uygun olarak bilinmeyen değerler için default dalı var. timeoutMs çağrının deadline'ı, abortSignal da iptal sinyali; ikinci yazıdaki iki kavramın tarayıcıdaki karşılıkları. Sayfaya üç rozet koydum: var olan bir kitap, kaydı olmayan bir kitap ve geçersiz bir id. Tarayıcıda görünen:
Book detail
In stock: 10 available, 249.90 TRY
Updated 2026-11-13T21:47:26.293Z
NotFound: Book 00000000-0000-0000-0000-000000000000 has no stock record.
InvalidArgument: Invalid request.
Status kodları ve mesajlar .NET client'ındakiyle aynı. Ağ sekmesinde ise her çağrı için iki istek vardı:
OPTIONS http://localhost:5103/bookstore.inventory.v1.InventoryService/GetStock → 204 No Content
POST http://localhost:5103/bookstore.inventory.v1.InventoryService/GetStock → 200 OK
OPTIONS CORS'un preflight isteği. gRPC-Web isteğinin içerik tipi (application/grpc-web+proto) "basit istek" sayılmadığı için tarayıcı çağrıdan önce izin soruyor; aynı anda başlayan üç çağrı için üç OPTIONS gitti. Angular uygulamasını ve API'yi aynı origin'den sunarsan (bir reverse proxy arkasında) preflight tamamen kalkar. Ayrı origin şartsa SetPreflightMaxAge ile izni tarayıcıda önbelleğe aldırmak gidiş-dönüş sayısını azaltır.
JSON Transcoding: Aynı Service, REST Yüzü
Mobil ekip için ayrı bir REST API yazmak yerine aynı gRPC method'larını JSON olarak yayınlıyoruz. Contract'a HTTP kuralları ekleniyor:
import "google/api/annotations.proto";
service InventoryService {
rpc GetStock (GetStockRequest) returns (GetStockResponse) {
option (google.api.http) = {
get: "/v1/books/{book_id}/stock"
};
}
rpc ReserveStock (ReserveStockRequest) returns (ReserveStockResponse) {
option (google.api.http) = {
post: "/v1/orders/{order_id}/reservations"
body: "*"
};
}
rpc WatchStock (WatchStockRequest) returns (stream StockChanged) {
option (google.api.http) = {
get: "/v1/stock-changes"
};
}
}
{book_id} route'tan request mesajının field'ına bağlanır. body: "*" route'ta kullanılmayan bütün field'ların JSON gövdesinden okunacağını söyler. Inventory'nin csproj'una <IncludeHttpRuleProtos>true</IncludeHttpRuleProtos> ekleyince Microsoft.AspNetCore.Grpc.JsonTranscoding paketi gerekli googleapis dosyalarını kendisi sağlıyor. Aynı .proto'yu derleyen client projelerinde (Ordering, StockCheck) ise Google.Api.CommonProtos paketi ve <IncludeGoogleApiCommonProtos>true</IncludeGoogleApiCommonProtos> gerekti; yoksa build Import "google/api/annotations.proto" was not found hatası veriyor. Server'da tek satır:
builder.Services.AddGrpc(options =>
{
// kayıt sırası = çalışma sırası: logging en dışta
options.Interceptors.Add<ServerLoggingInterceptor>();
options.Interceptors.Add<ExceptionMappingInterceptor>();
}).AddJsonTranscoding(options =>
{
// rich error detayları JSON'a çevrilebilsin
options.TypeRegistry = TypeRegistry.FromMessages(BadRequest.Descriptor, PreconditionFailure.Descriptor);
// JavaScript'in number'ı 2^53 üstünü kaybeder; proto JSON kuralı gibi string yaz
options.JsonSettings.WriteInt64sAsStrings = true;
});
Bu iki ayar sonradan eklendi; neden gerektiklerini aşağıda görüyoruz. Önce curl ile REST yüzü:
GET /v1/books/3f2c9a1e-7b4d-4c8e-9a51-2d6f0e8b7c13/stock
HTTP/1.1 200 OK
{"bookId":"3f2c9a1e-7b4d-4c8e-9a51-2d6f0e8b7c13","available":12,"reservedQuantity":3,"status":"STOCK_STATUS_IN_STOCK","unitPrice":{"currencyCode":"TRY","amount":{"units":"249","nanos":900000000}},"updatedAt":"2026-11-14T09:30:00Z","warehouseQuantities":{}}
GET /v1/books/00000000-0000-0000-0000-000000000000/stock
HTTP/1.1 404 Not Found
{"code":5,"message":"Book 00000000-0000-0000-0000-000000000000 has no stock record.","details":[]}
GET /v1/books/42/stock
HTTP/1.1 400 Bad Request
{"code":3,"message":"Invalid request.","details":[{"@type":"type.googleapis.com/google.rpc.BadRequest","fieldViolations":[{"field":"book_id","description":"'42' is not a valid UUID.","reason":""}]}]}
Transcoding gRPC status'unu HTTP status'una çeviriyor: NotFound 404, InvalidArgument 400 oluyor. Gövde ise ProblemDetails değil, google.rpc.Status'un JSON hâli. [Authorize] da aynen geçerli. Token'sız POST /v1/orders/o-55/reservations 401 döndü, inventory.write token'ıyla:
POST /v1/orders/o-55/reservations
{"lines":[{"bookId":"3f2c9a1e-7b4d-4c8e-9a51-2d6f0e8b7c13","quantity":2}]}
HTTP/1.1 200 OK
{"reservationId":"r-45526c9c","expiresAt":"2026-11-13T22:02:26.293220Z"}
Server streaming de destekleniyor, satır satır JSON olarak. Rezervasyondan hemen önce açtığım curl -N http://localhost:5103/v1/stock-changes şunu aldı:
{"sequence":"1","bookId":"3f2c9a1e-7b4d-4c8e-9a51-2d6f0e8b7c13","available":10,"changedAt":"2026-11-13T21:47:26.293097Z"}
Client ve bidirectional streaming ise transcoding ile yayınlanamıyor; BulkUpdateStock ve ScanBarcodes REST yüzünde yok.
Proto JSON Kuralları Client'ı Nasıl Etkiler?
JSON yüzünün şekli C# DTO'larınca (katmanlar arasında veri taşıyan, davranışsız class) değil protobuf'ın JSON eşleme kurallarınca belirleniyor. Mobil ekibin bilmesi gerekenler:
- Field adları lowerCamelCase:
book_id→bookId. İlk yazıda gördüğümüz gibi field'ı yeniden adlandırmak JSON client'larını kırar. - Enum'lar adıyla gider:
"STOCK_STATUS_IN_STOCK", C# enum'undakiInStockdeğil. TimestampRFC 3339 formatında, UTC (Coordinated Universal Time) bir string,Duration"1.5s"gibi bir string.- Varsayılan değerdeki field'lar da yazılıyor:
"warehouseQuantities":{}.IgnoreDefaultValuesayarı bunları atar, ama o zaman client "0" ile "yok"u ayırt edemez.
Baştaki ikinci sorunun cevabı ise sürpriz oldu. İlk denemede WriteInt64sAsStrings yoktu ve yanıt şuydu:
{"bookId":"3f2c9a1e-7b4d-4c8e-9a51-2d6f0e8b7c13","available":12,"reservedQuantity":3,"status":"STOCK_STATUS_IN_STOCK","unitPrice":{"currencyCode":"TRY","amount":{"units":249,"nanos":900000000}},"updatedAt":"2026-11-14T09:30:00Z","warehouseQuantities":{}}
"units":249, yani sayı. İlk yazıda Google.Protobuf'ın JsonFormatter'ı aynı alanı "249" olarak, string yazmıştı; protobuf'ın JSON spesifikasyonu da int64 için string öngörüyor. ASP.NET Core'un transcoding'i ise System.Text.Json tabanlı kendi serializer'ını kullanıyor ve varsayılanı sayı. 249 için fark etmez. Ama 2⁵³'ten büyük bir id ya da kuruş cinsinden büyük bir toplam, JavaScript'te sessizce yuvarlanır. Aynı contract'ı hem JsonFormatter hem transcoding ile yayınlıyorsan iki farklı JSON üretirsin. Bu yüzden WriteInt64sAsStrings = true ile kurala uyduk.
Üçüncü sorunun cevabı da bir tuzak. TypeRegistry ayarı yokken geçersiz id isteği 400 yerine boş gövdeli bir 500 Internal Server Error döndü. Server logunda:
System.InvalidOperationException: Type registry has no descriptor for type name 'google.rpc.BadRequest'.
Rich error'ın detayları Any içinde paketli. JSON'a çevirmek için serializer'ın o tipin descriptor'ını bilmesi gerekiyor. gRPC client'ları bu sorunu hiç yaşamadı, çünkü detay orada binary olarak taşınıyor. Kullandığın her detay tipini registry'ye eklemelisin.
OpenAPI: Grpc.Swagger'a Ne Oldu?
REST yüzünün dokümantasyonu için yıllardır önerilen yol Microsoft.AspNetCore.Grpc.Swagger paketiydi: transcoding route'larından Swashbuckle ile OpenAPI dokümanı üretiyordu. Bu paket hiç 1.0 sürümüne ulaşmadı (son sürümler 0.10.x) ve .NET 11 sürüm notlarında deprecated olarak ilan edildi (dotnet/aspnetcore #67134). Gerekçe, paketin Microsoft.OpenApi yerine Swashbuckle'a bağlı olması ve az kullanılan deneysel bir paketin bakım yükü. Issue'da transcoding route'ları için resmî bir yerine geçen önerilmedi.
Yeni bir projede bu pakete bağlanma. Seçenekler şunlar: grpc-gateway ekosistemindeki protoc-gen-openapiv2 plugin'i aynı google.api.http annotation'larından OpenAPI üretebilir; dokümanı build'de üretip yayınlarsın. Ya da REST yüzünü transcoding yerine gerçek bir ASP.NET Core API olarak yazarsın; ASP.NET Core Web API serisinin OpenAPI yazısındaki araçların hepsi o zaman kullanılabilir.
Asıl soru şu: transcoding'i public API yapmalı mısın? Kendi mobil uygulaman ya da iç araçlar için harika; tek contract, tek implementasyon. Dışarıya açık bir API'de ise protobuf'ın izleri taşınır: "STOCK_STATUS_IN_STOCK" gibi enum adları, ProblemDetails yerine {code, message, details} hata gövdesi, string int64'ler, contract'ın evrimini .proto kurallarına bağlamak. Dış dünyaya ayrı bir REST facade, iç trafiğe gRPC çoğu zaman daha rahat bir ayrım.
Code-First gRPC: protobuf-net.Grpc Ne Zaman Mantıklı?
Bu seride contract hep .proto dosyasında. Bir alternatif de protobuf-net.Grpc ile code-first yaklaşım: contract C# interface'leri ve [DataContract] benzeri attribute'larla işaretlenmiş class'lar olarak yazılır, .proto dosyası yoktur. İki tarafı da .NET olan bir ekipte cazip: DTO'lar zaten C#'ta, kod üretme adımı yok, interface'i paylaşılan bir class library'den alırsın.
Polyglot bir ortamda ise bu avantaj tersine döner. Go ile yazılmış bir service ya da TypeScript client'ı contract'ı C# kodundan okuyamaz. .proto dosyası bu yazıda yaptığımız gibi dört farklı araca (Grpc.Tools, Buf, Protobuf-ES, JSON transcoding) aynı anda girdi oluyor. Bugün yalnız .NET olsa bile yarın bir Angular client'ı ya da başka bir dil gelecekse contract-first'te kal.
Üretimde Dikkat
CORS'u dar tut. AllowAnyOrigin ile gRPC-Web açmak, herhangi bir sitenin kullanıcının tarayıcısından API'ne istek atmasına izin vermek demek. Origin listesini açıkça yaz ve yalnız POST'a izin ver; gRPC-Web başka method kullanmıyor.
Tarayıcı client'ında iptal ve timeout'u eksik etme. Kullanıcı sayfadan çıktığında devam eden çağrılar boşuna server kaynağı tüketir. resource'un abortSignal'ını çağrıya geçirmek bunu tek satırda çözüyor.
Bundle boyutunu ölç. Üretilen kod ve Protobuf-ES runtime'ı tarayıcıya gidiyor. Bu üç rozetli sayfanın main bundle'ı 331 KB, sıkıştırılmış hâliyle 85 KB çıktı. Büyük contract'larda kod üretimine yalnız tarayıcının kullandığı .proto dosyalarını vermek farkı küçültür.
REST yüzünde authorization'ı tekrar düşün. Transcoding endpoint'leri aynı [Authorize] kurallarını kullanır, ama artık tarayıcıdan ve dış ağdan erişilebilirler. Rate limiting, istek boyutu ve kaynak tüketimi kontrolleri API Güvenliği serisindeki API4 (Unrestricted Resource Consumption) yazısında anlattığımız gibi bu yüze de uygulanmalı.
Sık Düşülen Tuzaklar
WithExposedHeaders'ı unutmak. Başarılı çağrılar çalışır, hatalar anlamsız bir ağ hatasına dönüşür; sorun genelde ilk hatada fark edilir.
TLS'siz Http2 endpoint'ine tarayıcıdan bağlanmaya çalışmak. Tarayıcı h2c konuşmaz; lokal geliştirmede ayrı bir Http1 endpoint'i ya da TLS gerekir.
Rich error tiplerini TypeRegistry'ye eklememek. gRPC client'larında sorunsuz çalışan bir hata, JSON yüzünde 500'e dönüşür.
JSON yüzünde field adını değiştirmek. Binary client'lar fark etmez, mobil uygulama kırılır. İlk yazıdaki uyumluluk tablosu transcoding için de geçerli.
int64'ü tarayıcıda Number ile kullanmak. Protobuf-ES bigint veriyor. Fiyat gibi küçük değerlerde Number(...) güvenli, ama id ve büyük sayaçlarda değer bozulur.
Küçük Bir Deneme
★☆☆ CORS politikasından WithExposedHeaders satırını kaldır ve Angular sayfasını yenile. İkinci ve üçüncü rozette hangi hata mesajının göründüğünü not et.
★★☆ Kitap detay sayfasına WatchStock'u server streaming olarak bağla. Connect-ES'te for await ile okunan bir async iterable dönüyor. Bir terminalden rezervasyon yapınca rozetin canlı güncellendiğini gör.
★★★ ReleaseStock için delete: "/v1/reservations/{reservation_id}" kuralını ekle. oneof içindeki bir field'ın route'a bağlanmasının transcoding'de nasıl çalıştığını curl ile test et ve order_id ile bırakmak için ikinci bir route gerekip gerekmediğini tartış.
gRPC tarafı burada tamamlanıyor: contract, service, streaming, production ayarları ve tarayıcı. Bir sonraki yazıda serinin ikinci yarısına, GraphQL'e geçiyoruz. Aynı kitap detay ekranı bu kez tek bir sorgu olacak: kitap, yazar, stok ve son yorumlar, ekranın istediği alanlar kadar.