Mert Özen Her Satırda Daha İleri
Frontend Flutter ile Mobil — #13

Flutter'da API Entegrasyonu: http/dio, JSON Serialization, Repository ve ASP.NET Core Bookstore API'ye Bağlanmak

Mert Özen 15 Kas 2026 12 dk 5 görüntülenme
Flutter'da API Entegrasyonu: http/dio, JSON Serialization, Repository ve ASP.NET Core Bookstore API'ye Bağlanmak

Flutter uygulamasını bir ASP.NET Core API'ye bağlamanın sağlam yolu, HTTP'yi tek bir ApiClient'ta toplamak (timeout, yalnız GET için retry, 401'de tek refresh), JSON'u domain modeline tek noktada çevirmek ve repository'yi değiştirip ViewModel'e dokunmamaktır. Bookstore API'nin SQLite kopyasına karşı gerçek yanıtlar, ProblemDetails eşlemesi, 10.0.2.2, cleartext ve CORS tuzaklarıyla.

Sürüm notu: Bu yazıdaki örnekler Flutter 3.47.8 (Dart 3.13.7), http 1.6.0, material_ui 1.11.0, provider 6.1.5+1 ve go_router 18.1.4 ile; API tarafı .NET 10 (SDK 10.0.402), Entity Framework Core SQLite 10.0.15 ve ASP.NET Core Identity 10.0.15 ile denenmiştir. Çıktılar flutter test koşularından, API'ye gönderilen curl isteklerinden ve web build'inin Playwright'ın headless Chromium'unda açılmasından. Android emulator, iOS simulator ve gerçek cihaz çalıştırılmadı; o kısımlar dokümana göre anlatılıyor.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) API'si Jane Eyre'ın fiyatını JSON'da 269.9 olarak gönderiyor. Uygulamada para, kuruş cinsinden bir int (dördüncü yazıdaki Money). Dönüşümü şöyle yazarsan:

final kurus = (269.9 * 100).toInt();

kurus kaç olur? 26990 mı? Cevap, JSON'un uygulamaya girdiği tek noktayı neden dikkatle yazmamız gerektiğini gösteriyor.

Bookstore API'yi Yerelde Çalıştırmak

Şimdiye kadar katalog SampleCatalog'dan, siparişler sabit bir listeden geliyordu. Bu yazıda uygulamayı Bookstore'un ASP.NET Core API'sine bağlıyoruz. API'nin kendisi ASP.NET Core Web API serisinin konusu; burada repodaki Bookstore.Api'yi api/ klasörüne kopyalayıp mobil uygulama için dört şey değiştirdik:

  • SQL Server yerine SQLite: API container gerektirmeden çalışsın diye. SQL Server migration'ları SQLite'ta kullanılamadığı için Development'ta EnsureCreated ile şema ve seed oluşturuluyor.
  • Giriş: ASP.NET Core Identity'nin hazır endpoint'leri MapIdentityApi ile /api/auth altında (/register, /login, /refresh).
  • Siparişler giriş ister: /api/orders grubu RequireAuthorization() ile korunuyor ve kullanıcının siparişlerini dönen GET /api/orders eklendi.
  • Yalnız Development'ta, yalnız ayardaki origin'lere CORS (Cross-Origin Resource Sharing; tarayıcının başka bir origin'e istek atılmasına izin verme kuralı). Mobil uygulamanın buna ihtiyacı yok; web build'i için gerekiyor, aşağıda göreceğiz.
dotnet run --project api/Bookstore.Api -- --urls http://127.0.0.1:52813

Launch profile'daki varsayılan port 5080; bu yazıdaki çıktılar 52813 portundan. API ayaktayken gerçek yanıtlar:

GET /api/books?page=1&pageSize=2
{"items":[{"isbn":"9780451524935","title":"1984","author":"George Orwell","category":"Dystopian","price":279.0,"publishedYear":1949},{"isbn":"9780143035008","title":"Anna Karenina","author":"Leo Tolstoy","category":"Classics","price":429.0,"publishedYear":1878}],"total":20,"page":1,"pageSize":2}
GET /api/books/0000000000000
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not Found","status":404,"traceId":"00-a8d2ff1b54e8aaf47695883a1cfb6aba-13f33ce1c1a1d652-00"}
POST /api/orders
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer

Son istek token'sız gönderildi. Hata yanıtları ProblemDetails formatında; onuncu yazıda form alanlarına eşlediğimiz errors sözlüğü de bu formatın parçası.

http mi dio mu? ApiClient: Tek HTTP Kapısı

Flutter'da iki yaygın HTTP paketi var. http Dart ekibinin paketi: küçük, her platformda çalışıyor (web'de tarayıcının fetch'ini kullanıyor) ve testler için MockClient ile geliyor. dio ise interceptor zinciri, istek iptali için CancelToken, ayrı connect/receive timeout'ları, upload/download ilerlemesi gibi hazır parçalar sunuyor; aynı anda gelen 401'leri sıraya koyan QueuedInterceptor da var. Seçim kriteri basit: bu özelliklerin çoğuna ihtiyacın varsa dio, değilse http ve kendi küçük sarmalayıcın. Bookstore'da http seçtik, çünkü ihtiyacımız olan üç şey (timeout, retry, token) birkaç düzine satır ve tümü test edilebilir tek bir class'ta duruyor.

Resmî mimari rehberinde servis, dış dünyayı saran ve state tutmayan katman. ApiClient uygulamada API ile konuşan tek class: repository'ler ondan JSON alır, hiçbir zaman Response görmez ve her hata Result olarak döner:

Future<Result<Object?>> get(
  String path, {
  Map<String, String>? query,
  bool auth = false,
}) => _send('GET', path, query: query, auth: auth, retries: 2);

// POST ve PUT tekrar edilmez: ikinci POST /api/orders ikinci bir sipariştir
Future<Result<Object?>> post(
  String path,
  Object? body, {
  bool auth = false,
}) => _send('POST', path, body: body, auth: auth);
for (var attempt = 0; ; attempt++) {
  try {
    var response = await _request(method, uri, body, auth: auth);
    if (response.statusCode == 401 && auth && await _refreshTokens()) {
      response = await _request(method, uri, body, auth: auth);
    }
    // 5xx geçici olabilir; 4xx tekrar sormakla değişmez
    if (response.statusCode < 500 || attempt >= retries) {
      return _decode(response);
    }
  } on TimeoutException {
    if (attempt >= retries) {
      return const Result.failure(NetworkException('timeout'));
    }
  } on http.ClientException catch (e) {
    if (attempt >= retries) {
      return Result.failure(NetworkException(e.message));
    }
  }
  await Future<void>.delayed(retryDelay * (attempt + 1));
}

Timeout her isteğe .timeout(timeout) ile ekleniyor (varsayılan 10 saniye); package:http'nin kendisi istek için bir süre sınırı koymuyor. Retry yalnız GET'te ve yalnız 5xx, timeout ya da bağlantı hatasında. MockClient ile yazdığımız testlerin çıktısı:

GET: Ok<Object?> after 3 attempts
POST: HTTP 503 after 1 attempt
Could not reach the server (timeout)

JSON'dan Domain Modeline

"Önce Tahmin Et"in cevabı testte:

269.9 * 100 = 26989.999999999996
(269.9 * 100).toInt() = 26989
moneyFromJson(269.9) = 26990 kurus

C#'taki decimal JSON'a sayı olarak yazılıyor, jsonDecode onu double yapıyor ve 269.9 ikili tabanda tam ifade edilemiyor. toInt() kesirli kısmı atınca bir kuruş kayboluyor. Bu yüzden double'ın paraya dokunduğu tek yer moneyFromJson; değer orada round() ile bir kez kuruşa çevriliyor. Kitap dönüşümü de aynı dosyada, Dart 3'ün map pattern'leriyle:

Money moneyFromJson(num value) => Money((value * 100).round());

Book bookFromJson(Object? json) => switch (json) {
  final Map<String, Object?> map &&
      {
        'isbn': String isbn,
        'title': String title,
        'author': String author,
        'price': num price,
        'publishedYear': int year,
      } =>
    Book(
      isbn: isbn,
      title: title,
      author: Author(name: author),
      price: moneyFromJson(price),
      publishedYear: year,
      // Açıklamayı yalnız detay endpoint'i gönderiyor
      description: map['description'] as String?,
    ),
  _ => throw FormatException('Not a book: $json'),
};

Pattern hem alanların varlığını hem tiplerini tek seferde kontrol ediyor; beklenmeyen bir JSON FormatException olur ve repository onu Failure'a çevirir. price için double değil num kullandık: SQLite'taki API 279.0 gönderiyor, ama başka bir veritabanında aynı fiyat 279 gelebilir ve double pattern'i int ile eşleşmez.

Alternatif kod üretimi. json_serializable 6.15.2 ile aynı modeli ayrı bir deneme paketinde ürettik: @JsonSerializable() ve part satırını yazınca dart run build_runner build fromJson/toJson'ı üretiyor; ilk koşu "Built with build_runner/aot in 11s", değişiklik sonrası koşu 0 saniye sürdü. freezed (4.0.3) bunun üstüne immutable model, copyWith, değer eşitliği ve union tipleri üretiyor; onu çalıştırmadık. Ölçüt: onlarca DTO'su (Data Transfer Object; API'nin gönderdiği veri şekli) olan bir API'de üretilen kod hata yüzeyini küçültür; Bookstore'un üç JSON şekli için elle yazılmış, pattern'li dönüşüm hem daha kısa hem build adımı gerektirmiyor.

Repository'yi Değiştirmek, ViewModel'e Dokunmamak

On birinci yazıda BookRepository'yi abstract tutmamızın karşılığı burada alınıyor. ApiBookRepository aynı iki method'u API ile yazıyor:

@override
Future<Result<List<Book>>> getPage(int page) async {
  // Uygulama sayfaları 0'dan, API 1'den sayıyor
  final result = await _api.get(
    '/api/books',
    query: {'page': '${page + 1}', 'pageSize': '$pageSize'},
  );
  return switch (result) {
    Ok(value: {'items': List<Object?> items}) => parseJson(
      () => [for (final item in items) bookFromJson(item)],
    ),
    Ok(:final value) => Result.failure(FormatException('Not a page: $value')),
    Failure(:final error) => Result.failure(error),
  };
}

@override
Future<Result<Book>> getByIsbn(String isbn) async {
  return switch (await _api.get('/api/books/$isbn')) {
    Ok(:final value) => parseJson(() => bookFromJson(value)),
    Failure(error: ApiException(statusCode: 404)) => Result.failure(
      BookNotFound(isbn),
    ),
    Failure(:final error) => Result.failure(error),
  };
}

404, uygulamanın zaten tanıdığı BookNotFound'a çevriliyor; detay sayfası "This book could not be found." durumunu değişmeden gösteriyor. CatalogViewModel ve BookDetailsViewModel'de tek satır değişmedi. Değişen tek yer object graph'ı: main artık API'li repository'leri veriyor, testler ve widget preview'lar sample veriyle kalıyor:

/// main()'in kullandığı graph: her repository Bookstore.Api ile konuşur.
List<SingleChildWidget> apiProviders(ApiClient api) => appProviders(
  bookRepository: ApiBookRepository(api),
  authRepository: ApiAuthRepository(api),
  orderRepository: ApiOrderRepository(api),
);

Siparişler ekranı da sabit listeden kurtuldu: yeni OrdersViewModel, OrderRepository'den yüklüyor ve repository yeni bir sipariş oluşturulunca bildirim yaptığı için liste kendiliğinden tazeleniyor.

Sipariş Vermek ve ProblemDetails

API sepeti sunucuda tutuyor; uygulamanın sepeti ise cihazda. Siparişte ApiOrderRepository önce bir sepet oluşturuyor, satırları ekliyor, sonra siparişi veriyor. Her adım bir öncekinin cevabına bağlı olduğu için bu await'ler sıralı. Testte giden istekler:

POST /api/carts
PUT /api/carts/01a1dde5-644f-7a17-8756-48f00ede6927/items/9780441172719 {"quantity":2}
POST /api/orders {"cartId":"01a1dde5-644f-7a17-8756-48f00ede6927","customerName":"Ada Lovelace","customerEmail":"ada@example.com"}
order 1, total 978.00 TRY

Toplamı sunucu kendi fiyatlarıyla hesaplıyor; uygulamadaki fiyat yalnız gösterim için. API'nin doğrulama hataları formdaki alanlarla aynı adı taşımıyor. 201 karakterlik bir isimle gönderdiğimiz siparişin gerçek yanıtı:

HTTP/1.1 400 Bad Request
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"CustomerName":["The field CustomerName must be a string with a maximum length of 200."]},"traceId":"00-33bb513dd8aa1f1840feac054853229b-b61b78e44a0c78f9-00"}

Form bu alana "Full name" diyor. Eşlemeyi ekrana değil data katmanına, tek bir fonksiyona koyduk:

/// API alıcıya CustomerName, adres formu FullName diyor.
/// Ad değişikliği ekranda değil, burada ve bir kez yapılır.
Map<String, Object?> toFormProblem(Map<String, Object?> problem) => {
  ...problem,
  if (problem['errors'] case final Map<String, Object?> errors)
    'errors': {
      for (final MapEntry(:key, :value) in errors.entries)
        key == 'CustomerName' ? 'ShippingAddress.FullName' : key: value,
    },
};
400: {FullName: The field CustomerName must be a string with a maximum length of 200.}
Not enough stock for: 9780060934347.

İkinci satır stoku sıfır olan Don Quixote'un 409 yanıtı. Bu yanıtta errors yok, yalnız detail var; onuncu yazıdaki form bunu göstermiyordu. DeliveryAddressScreen'e alan hatası olmayan bir problemde detail'ı formun üstünde gösteren küçük bir dal ekledik ve widget testiyle doğruladık.

Token ve 401'de Refresh

Giriş artık gerçek: ApiAuthRepository.signIn, POST /api/auth/login'e e-posta ve parola gönderiyor ve yanıttaki iki token'ı TokenStore'a yazıyor. Token ömrünü 1 dakikaya ayarladığımız bir koşudaki yanıt (token'ları kısalttık, expiresIn saniye cinsinden):

{'tokenType': 'Bearer', 'accessToken': '<710 chars>', 'expiresIn': 60, 'refreshToken': '<710 chars>'}

Bu token'lar JWT (JSON Web Token; imzalı, içi okunabilir token formatı) değil; ASP.NET Core'un kendi korumalı formatı. İstemci için fark yok: access token Authorization: Bearer header'ında gidiyor, süresi dolunca refresh token ile yenisi alınıyor. JWT'nin üretimi ASP.NET Core Web API serisindeki "ASP.NET Core Web API'de JWT ile Authentication: Token Üretmek ve Doğrulamak" yazısında, refresh token'ın sunucu tarafı Authentication ve Authorization serisindeki "Refresh Token'lar: Rotation, Reuse Detection, Revocation ve Sunucu Tarafında Güvenli Saklama" yazısında.

Header'ı her isteğe elle eklemek yerine ApiClient auth: true olan isteklerde ekliyor; 401 gelirse bir kez refresh deneyip aynı isteği yeniden gönderiyor. Bu, dio'daki interceptor'ın ve Angular'daki HttpInterceptor'ın yaptığı işin aynısı:

13-token-refresh-tr.png

/// Aynı anda birkaç istek 401 alabilir; hepsi tek bir refresh'i paylaşır.
Future<bool> _refreshTokens() =>
    _refreshing ??= _refresh().whenComplete(() => _refreshing = null);

Bu satır olmadan, katalog ve siparişler aynı anda 401 aldığında iki ayrı refresh gider ve token rotation yapan bir sunucuda ikincisi reddedilir. Paralel iki istekli testin gerçek log'u:

GET /api/orders Bearer old-access
GET /api/orders Bearer old-access
POST /api/auth/refresh
GET /api/orders Bearer new-access
GET /api/orders Bearer new-access
results: Ok<Object?>, Ok<Object?>

Refresh de reddedilirse ApiClient token'ları siliyor ve onSessionExpired'ı çağırıyor; ApiAuthRepository bildirim yapıyor ve on ikinci yazıdaki redirect kullanıcıyı giriş sayfasına götürüyor. Aynı akışı gerçek API'ye karşı da çalıştırdık: API'yi access token ömrü 6 saniye olacak şekilde başlattık (--Auth:AccessTokenMinutes=0.1), test/live'daki test uygulamanın repository'leriyle giriş yaptı, sipariş verdi, 8 saniye bekledi ve siparişleri istedi:

  GET /api/books -> 200
page 0: 20 books, first: 1984 279.00 TRY, Anna Karenina 429.00 TRY, Brave New World 299.00 TRY
  POST /api/auth/login -> 401
wrong password: The email or password is not correct.
  POST /api/auth/login -> 200
signed in: true
  GET /api/books/9780441172719 -> 200
  POST /api/carts -> 201
  PUT /api/carts/01a1dde6-0e91-7b85-a4a9-e325c5a78425/items/9780441172719 -> 200
  POST /api/orders -> 201
order 2: 489.00 TRY
waiting for the access token to expire...
  GET /api/orders -> 401
  POST /api/auth/refresh -> 200
  GET /api/orders -> 200
my orders: 2, 1

Bu yazıda token'lar bellekte (InMemoryTokenStore); uygulama kapanınca kullanıcı tekrar giriş yapmak zorunda. Cihazda güvenli saklamak bir sonraki yazının konusu.

Emulator, Simulator ve Web: Yerel Geliştirme Tuzakları

API adresi build anında veriliyor: String.fromEnvironment('API_BASE_URL'), varsayılanı http://127.0.0.1:5080. Uygulamanın nerede çalıştığına göre bu adres değişir:

  • Android emulator: emulator'ün içinde 127.0.0.1 emulator'ün kendisi; geliştirme makinesine 10.0.2.2 ile ulaşılır: flutter run --dart-define=API_BASE_URL=http://10.0.2.2:5080.
  • Android'de düz HTTP: API 28'i (Android 9) hedefleyen uygulamalarda cleartext trafik varsayılan olarak kapalı. Yalnız debug build için network_security_config.xml ile 10.0.2.2 ve 127.0.0.1'e izin verdik. Ayrıca şablondaki INTERNET izni yalnız debug manifest'indeydi; release build'de ağ çalışmasın diye değil, unutulduğu için. Ana manifest'e ekledik.
  • iOS simulator geliştirme makinesinin ağını kullanır, 127.0.0.1 çalışır. ATS (App Transport Security; iOS'un şifresiz bağlantıları engelleyen kuralı) düz HTTP'yi engeller; yerel geliştirme için Info.plist'te NSAllowsLocalNetworking anahtarı var. IP adreslerinin bu kuraldan muaf olup olmadığı konusunda Apple'ın eski dokümanı ile geliştirici gözlemleri çelişiyor; bu yüzden cihazda denemeden kesin bir şey yazmıyoruz.
  • Gerçek cihaz: geliştirme makinesinin yerel ağdaki adresi ve API'nin yalnız loopback'e değil o adrese de bağlanması gerekir.

Bu adımlar cihaz ve emulator gerektirdiği için çalıştırılmadı. Web ise ayrı bir tuzak getiriyor. Web build'ini http://127.0.0.1:52823'ten servis edip Chromium'da açtığımızda, API'de CORS ayarı yokken:

#/catalog
  GET /api/books -> failed (net::ERR_FAILED)
  GET /api/books -> failed (net::ERR_FAILED)
  GET /api/books -> failed (net::ERR_FAILED)
  shows: could not, Try again
  console error: Access to fetch at 'http://127.0.0.1:52813/api/books?page=1&pageSize=20' from origin 'http://127.0.0.1:52823' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

İki şey görülüyor. Birincisi port farkı bile origin'i değiştiriyor, tarayıcı yanıtı uygulamaya vermiyor. İkincisi CORS hatası Dart tarafına sıradan bir ClientException olarak geliyor; retry onu geçici bir ağ hatası sandı ve isteği üç kez denedi. API'yi --Cors:DevOrigins:0=http://127.0.0.1:52823 ile başlatınca:

#/catalog
  GET /api/books?page=1&pageSize=20 -> 200
  shows: 1984, Anna Karenina, Dune
#/catalog/books/9780441172719
  GET /api/books?page=1&pageSize=20 -> 200
  GET /api/books/9780441172719 -> 200
  shows: Dune, A desert planet

Üretimde Dikkat

HTTPS zorunlu. Debug'da açtığımız cleartext izinleri yalnız src/debug altında; release build'e taşınmamalı. Production API adresi build sırasında verilir ve bir sonraki yayınlama yazısında flavor'larla yönetilir.

Timeout'u ölç. 10 saniye mobil ağda bile uzun bir bekleme; kullanıcı yükleniyor göstergesine bu kadar bakmaz. Ama çok kısa bir timeout, yavaş bağlantıda her isteği hataya çevirir. Gerçek kullanıcı verisinden (istek sürelerinin dağılımı) karar ver.

Retry'ı sınırla ve yalnız idempotent isteklerde kullan. Bizim GET'imiz en fazla üç deneme yapıyor ve bekleme süresi her denemede artıyor; sunucu zaten zorlanıyorsa binlerce cihazın aynı anda tekrar denemesi işi kötüleştirir. POST siparişte ağ koparsa sipariş oluşmuş olabilir; burada doğru çözüm sunucu tarafında bir idempotency key, uygulamada ise sipariş listesini tazelemek.

Uygulamayı yeni bir sürüme zorlayamazsın. Store'daki eski sürümler aylarca API'yi çağırmaya devam eder; JSON'a alan eklemek güvenli, alan silmek ya da adını değiştirmek değil. Pattern tabanlı dönüşümün bilinmeyen alanları yok sayması bu yüzden işe yarıyor.

Sık Düşülen Tuzaklar

Birinci tuzak, para için double ile hesap yapmak ya da toInt() ile kesmek. Yazının başındaki bir kuruş, binlerce siparişte muhasebe farkı olur.

İkinci tuzak, http.get'i widget'ın ya da ViewModel'in içinde çağırmak. Hata yönetimi, header ve timeout her yere dağılır, test için gerçek sunucu gerekir.

Üçüncü tuzak, testte gerçek ağa güvenmek. testWidgets içindeki HTTP istekleri gerçek sunucuya gitmez (sekizinci yazıda Image.network'ün aldığı statusCode: 400); ağ katmanını MockClient ya da fake repository ile test et, gerçek API'yi ayrı ve etiketli (@Tags(['live'])) bir testte dene.

Dördüncü tuzak, token yenilemeyi her 401'de ayrı ayrı yapmak. Paralel istekler birbirinin refresh token'ını geçersiz kılar ve kullanıcı sebepsiz yere çıkış yapmış olur.

Küçük Bir Deneme

★☆☆ ApiClient'a her isteğe Accept-Language header'ı ekleyen bir parametre ekle ve MockClient ile header'ın gittiğini doğrula.

★★☆ Katalog ekranına arama ekle: API'nin search query parametresini BookRepository'ye taşı ve CatalogViewModel'de 300 ms debounce ile çağır. "austen" araması için API kaç kitap dönüyor?

★★★ Sipariş POST'unda yanıt gelmeden bağlantı koparsa ne olur? API'ye Idempotency-Key header'ını okuyup aynı anahtarla gelen ikinci isteğe ilk siparişi dönen bir kontrol ekle, uygulamada anahtarı sepet başına bir kez üret ve testle iki POST'un tek sipariş oluşturduğunu göster.

Bir sonraki yazıda uygulamayı cihazda kalıcı hale getiriyoruz: ayarlar için shared_preferences, token için flutter_secure_storage, katalog cache'i için SQLite ve drift, internet yokken de çalışan bir katalog ve uygulama kapanınca kaybolmayan bir sepet.